pairling 0.2.10 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,6 @@ PACKAGED_SOURCE_PATHS=(
33
33
  "mac/connectd/internal"
34
34
  "mac/connectd/go.mod"
35
35
  "mac/connectd/go.sum"
36
- "mac/guardian"
37
36
  "mac/install"
38
37
  "mac/mcp"
39
38
  )
@@ -48,7 +47,6 @@ fi
48
47
 
49
48
  PAIRLING_RUNTIME_PORT="${PAIRLING_RUNTIME_PORT:-7773}"
50
49
  PAIRLING_DAEMON_LABEL="dev.pairling.companiond"
51
- PAIRLING_GUARDIAN_LABEL="dev.pairling.power-guardian"
52
50
  PAIRLING_CONNECTD_LABEL="dev.pairling.connectd"
53
51
  PAIRLING_PTYBROKER_LABEL="dev.pairling.ptybroker"
54
52
  APP_SUPPORT="${PAIRLING_APP_SUPPORT_ROOT:-${COMPANION_APP_SUPPORT_ROOT:-$HOME/Library/Application Support/Pairling}}"
@@ -57,6 +55,7 @@ RELEASES_ROOT="$RUNTIME_ROOT/releases"
57
55
  STATE_ROOT="$APP_SUPPORT/state"
58
56
  PAIR_ROOT="$APP_SUPPORT/pair"
59
57
  LOGS_ROOT="${PAIRLING_LOGS_ROOT:-${COMPANION_LOGS_ROOT:-$HOME/Library/Logs/Pairling}}"
58
+ PAIRDROP_ROOT="${PAIRLING_PAIRDROP_ROOT:-$HOME/PairDrop}"
60
59
  PLIST_BUILD_DIR="$RUNTIME_ROOT/plists"
61
60
  CURRENT_LINK="$RUNTIME_ROOT/current"
62
61
  PREVIOUS_LINK="$RUNTIME_ROOT/previous"
@@ -69,11 +68,9 @@ INSTALL_HISTORY="$STATE_ROOT/install-history.jsonl"
69
68
  USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_DAEMON_LABEL.plist"
70
69
  CONNECTD_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_CONNECTD_LABEL.plist"
71
70
  PTYBROKER_USER_PLIST="$HOME/Library/LaunchAgents/$PAIRLING_PTYBROKER_LABEL.plist"
72
- SYSTEM_PLIST="/Library/LaunchDaemons/$PAIRLING_GUARDIAN_LABEL.plist"
73
71
  MCP_SERVER_DIR="$HOME/.claude/mcp-servers"
74
72
  MCP_SERVER_SHIM="$MCP_SERVER_DIR/phone-tools.py"
75
73
  PYTHON3_BIN="${PAIRLING_DAEMON_PYTHON:-${COMPANION_DAEMON_PYTHON:-$(command -v python3)}}"
76
- GUARDIAN_PYTHON_BIN="${PAIRLING_GUARDIAN_PYTHON:-${COMPANION_GUARDIAN_PYTHON:-/usr/bin/python3}}"
77
74
  # P3 Python custody: the npm shim points PAIRLING_DAEMON_PYTHON at the vendored
78
75
  # CPython inside the platform runtime package (…/python/bin/python3). When that
79
76
  # is in play we stage the whole interpreter into the release tree and run the
@@ -100,6 +97,686 @@ is_dry_run() {
100
97
  [[ "$DRY_RUN" == "1" || "$DRY_RUN" == "true" || "$DRY_RUN" == "TRUE" ]]
101
98
  }
102
99
 
100
+ # launchd_skipped is true when a testing smoke run asked us not to touch launchd,
101
+ # so a real setup can render the polished screen without booting the dev.pairling
102
+ # agents. is_dry_run already suppresses these launchctl calls in a preview. This
103
+ # adds the explicit testing skip on top of that, mirroring the pattern in
104
+ # mac/testing/reset-first-run-state.sh. A normal run leaves the variable unset,
105
+ # so the check is false and launchctl runs exactly as before.
106
+ launchd_skipped() {
107
+ [ "${PAIRLING_TESTING_SKIP_LAUNCHD:-0}" = 1 ]
108
+ }
109
+
110
+ setup_intro() {
111
+ # When the guided screen is on and this is not a dry run, draw the optional
112
+ # one-shot splash tied to a real recheck. It is bash-only and skippable, and it
113
+ # never aborts setup. The three plain log lines below are the WIZARD_TUI=0
114
+ # fallback and they always print.
115
+ if [ "${WIZARD_TUI:-0}" = 1 ] && ! is_dry_run; then
116
+ wizard_splash || true
117
+ fi
118
+ log "Pairling setup"
119
+ log "This stages the Mac runtime and opens a pairing code for the iPhone."
120
+ log ""
121
+ }
122
+
123
+ # ----- Guided setup: TTY-aware stage/progress wrapper -----
124
+ # All guided output is plain printf and gated on one IS_TTY check, so it reads as
125
+ # a clear numbered flow in a normal terminal and degrades to plain prefixed lines
126
+ # in a pipe, in CI, or under the bootstrap-first-run.sh log capture. Setting
127
+ # PAIRLING_GUIDED_PLAIN=1 forces the ASCII form even on a TTY.
128
+ if [ -t 1 ] && [ "${PAIRLING_GUIDED_PLAIN:-0}" != "1" ]; then
129
+ GUIDED_TTY=1
130
+ else
131
+ GUIDED_TTY=0
132
+ fi
133
+ GUIDED_STAGE_TOTAL=8
134
+ GUIDED_STAGE_N=0
135
+ GUIDED_STAGE_CURRENT=""
136
+ GUIDED_COMPLETE=0
137
+ # Set to 1 only before an integrity or code signature exit so guided_on_exit
138
+ # shows the no-bypass fatal recovery menu instead of the recoverable one. It
139
+ # stays 0 for every recoverable failure. Declared here so it is always defined
140
+ # under set -u.
141
+ WIZARD_FATAL=0
142
+
143
+ # _machine_path_blocks: returns success when a machine condition should keep the
144
+ # polished screen off, so both gate functions share one authoritative guard list.
145
+ # It returns success for NO_COLOR, for CI, for a dry run, for the --json or
146
+ # --plan-only arguments, and for a dumb, unknown, or empty TERM. It returns
147
+ # failure when no machine condition blocks the screen. A future sixth guard is
148
+ # added here once, so want_tui and want_tui_tty stay authoritative on every path.
149
+ _machine_path_blocks() {
150
+ [ -n "${NO_COLOR:-}" ] && return 0
151
+ [ -n "${CI:-}" ] && return 0
152
+ [ -n "${PAIRLING_DRY_RUN:-}" ] && return 0
153
+ case " $* " in *" --json "*|*" --plan-only "*) return 0;; esac
154
+ case "${TERM:-dumb}" in dumb|unknown|"") return 0;; esac
155
+ return 1
156
+ }
157
+
158
+ # want_tui: the single decision that keeps the polished screen off every machine
159
+ # path. It returns success only when the guided screen should render. It returns
160
+ # failure for a non-terminal stdout and for every machine condition in
161
+ # _machine_path_blocks, which are NO_COLOR, CI, a dry run, the --json or
162
+ # --plan-only arguments, and a dumb or unknown terminal. The bash spine runs the
163
+ # plain numbered flow whenever this returns failure, so machine output stays
164
+ # byte-for-byte the same. The --json and --plan-only checks are defensive only,
165
+ # because those flags do not reach install_runtime on the setup arm today. The
166
+ # gate checks them so the function stays correct if a future caller ever threads
167
+ # them in.
168
+ want_tui() {
169
+ [ -t 1 ] || return 1
170
+ _machine_path_blocks "$@" && return 1
171
+ return 0
172
+ }
173
+
174
+ # want_tui_tty: the first-run variant of the gate. The first-run flow pipes our
175
+ # stdout through tee, so [ -t 1 ] is false even though a controlling terminal
176
+ # still exists, and want_tui returns failure for that reason alone. This variant
177
+ # skips only the stdout tty check. It still fails for every machine condition in
178
+ # _machine_path_blocks, so NO_COLOR, CI, a dry run, --json, --plan-only, and a
179
+ # dumb or unknown TERM still disable the screen on the first-run path. It then
180
+ # proves a controlling terminal with a real write to /dev/tty, so only a true
181
+ # controlling terminal enables the screen. The device node can test as writable
182
+ # with no controlling terminal, so the guard writes one empty line instead of
183
+ # testing -w.
184
+ want_tui_tty() {
185
+ _machine_path_blocks "$@" && return 1
186
+ { : >/dev/tty; } 2>/dev/null || return 1
187
+ return 0
188
+ }
189
+
190
+ # wizard_splash_verify: the real recheck the splash result is tied to. It runs a
191
+ # bounded integrity recheck of the staged release. Before staging there is no
192
+ # staged binary, so it returns 0, which means there is nothing to contradict yet.
193
+ # The splash runs in setup_intro, before copy_release, so this is bash-only and
194
+ # starts no python. A real per-file hash recheck happens later in the doctor gate,
195
+ # so the splash result is honest about what it can confirm at intro time.
196
+ wizard_splash_verify() {
197
+ local binary="$CURRENT_LINK/connectd/pairling-connectd"
198
+ if [ -x "$binary" ]; then
199
+ /usr/bin/codesign --verify --strict "$binary" >/dev/null 2>&1 || return 1
200
+ fi
201
+ return 0
202
+ }
203
+
204
+ # wizard_splash: an optional one-shot brand beat under about 800 milliseconds.
205
+ # It is bash-only, because it runs before the python is staged. It draws a boxed
206
+ # brand header, the brand word in the accent-to-e-ink gradient and the tagline in
207
+ # paper, then a short non-interactive spinner with sleep 0.08, which bash
208
+ # 3.2.57 allows, then prints a result tied to the recheck. It renders only when
209
+ # GUIDED_TTY is 1, so it follows the same guided screen gate as the numbered
210
+ # stages. GUIDED_TTY is 1 exactly when WIZARD_TUI is 1 and PAIRLING_GUIDED_PLAIN
211
+ # is not 1, so the splash renders on the first-run path where GUIDED_TTY is 1 and
212
+ # the knob is unset, it honors the PAIRLING_GUIDED_PLAIN opt-out and stays plain
213
+ # when that knob is set, and it stays silent on every machine path where
214
+ # GUIDED_TTY is 0. Whether it drops the spinner is decided by reduced_motion_on,
215
+ # which honors PAIRLING_REDUCED_MOTION first and otherwise reads the macOS
216
+ # ReduceMotionEnabled setting. When reduce motion is on it skips the spinner and
217
+ # still prints the brand line and the result line. This
218
+ # is the most cuttable piece of the wizard. If it ever adds risk, drop it and keep
219
+ # the plain intro.
220
+ # reduced_motion_on: decide whether the splash should drop its spinner. The
221
+ # explicit PAIRLING_REDUCED_MOTION knob wins when set to any non-empty value,
222
+ # matching the prior behavior. When it is unset, fall back to the macOS system
223
+ # setting com.apple.Accessibility ReduceMotionEnabled. defaults exits non-zero
224
+ # and prints nothing when that key was never written, so a missing key reads as
225
+ # reduce motion off and the helper returns non-zero, so the spinner runs. A
226
+ # stored value of 1 means reduce motion
227
+ # is on. DEFAULTS_BIN defaults to the pinned /usr/bin/defaults and exists only
228
+ # so tests can point it at a stub.
229
+ reduced_motion_on() {
230
+ if [ "${PAIRLING_REDUCED_MOTION:-}" != "" ]; then
231
+ return 0
232
+ fi
233
+ [ "$("${DEFAULTS_BIN:-/usr/bin/defaults}" read com.apple.Accessibility ReduceMotionEnabled 2>/dev/null)" = 1 ]
234
+ }
235
+ # wizard_palette_init: detect the terminal color tier and set the brand palette
236
+ # variables the guided splash, the box helpers, and the stage markers read. It
237
+ # mirrors the tier detection in mac/docs/setup-wizard-mockup-bash.sh. The tier is
238
+ # true when COLORTERM is truecolor or 24bit, else 256 when tput reports at least
239
+ # 256 colors, else 16 when tput reports at least 8, else none. NO_COLOR forces the
240
+ # none tier. Each palette variable carries a real escape string spelled with the
241
+ # file's \033[ CSI prefix, and is empty in the none tier so a no-color terminal
242
+ # gets plain text. WZ_ERR is a clear error red for the splash failure marker, kept
243
+ # distinct from the warm brand accent and consistent across tiers, so a failure
244
+ # never reads as the brand. The function is idempotent through the WZ_PALETTE_READY guard
245
+ # and never prints, so the load-time call, the defensive call in wizard_splash, and
246
+ # the calls in the stage markers cost only one string test after the first.
247
+ wizard_palette_init() {
248
+ [ "${WZ_PALETTE_READY:-0}" = 1 ] && return 0
249
+ WZ_TIER="none"
250
+ if [ -z "${NO_COLOR:-}" ]; then
251
+ if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then
252
+ WZ_TIER="true"
253
+ elif [ "$(tput colors 2>/dev/null || echo 0)" -ge 256 ]; then
254
+ WZ_TIER="256"
255
+ elif [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then
256
+ WZ_TIER="16"
257
+ fi
258
+ fi
259
+ case "$WZ_TIER" in
260
+ true)
261
+ WZ_ACCENT=$'\033[38;2;226;70;42m'; WZ_EINK=$'\033[38;2;168;51;28m'
262
+ WZ_PAPER=$'\033[38;2;231;228;216m'; WZ_OK=$'\033[38;2;120;170;90m'
263
+ WZ_GREY=$'\033[38;2;150;145;135m'; WZ_ERR=$'\033[38;2;208;48;48m' ;;
264
+ 256)
265
+ WZ_ACCENT=$'\033[38;5;166m'; WZ_EINK=$'\033[38;5;130m'
266
+ WZ_PAPER=$'\033[38;5;223m'; WZ_OK=$'\033[38;5;107m'; WZ_GREY=$'\033[38;5;244m'
267
+ WZ_ERR=$'\033[38;5;160m' ;;
268
+ 16)
269
+ WZ_ACCENT=$'\033[91m'; WZ_EINK=$'\033[31m'; WZ_PAPER=$'\033[37m'
270
+ WZ_OK=$'\033[32m'; WZ_GREY=$'\033[90m'; WZ_ERR=$'\033[31m' ;;
271
+ *)
272
+ WZ_ACCENT=""; WZ_EINK=""; WZ_PAPER=""; WZ_OK=""; WZ_GREY=""; WZ_ERR="" ;;
273
+ esac
274
+ WZ_PALETTE_READY=1
275
+ return 0
276
+ }
277
+
278
+ # wizard_gradient: print text in the brand gradient, accent (226,70,42) sweeping to
279
+ # e-ink (168,51,28), one color step per character. It ports the mockup gradient and
280
+ # runs the per-character sweep only in the true tier, where 24-bit color exists. In
281
+ # every other tier it prints the whole text in bold accent. bash 3.2.57 arithmetic
282
+ # evaluates the n>1 ternary, and the d guard keeps a single-character word from
283
+ # dividing by zero. It emits no trailing reset, so the caller closes the color.
284
+ wizard_gradient() {
285
+ local text="$1"
286
+ if [ "${WZ_TIER:-none}" != "true" ]; then
287
+ printf '\033[1m%s%s\033[0m' "${WZ_ACCENT:-}" "$text"
288
+ return 0
289
+ fi
290
+ local n=${#text} i=0 ch r g b d
291
+ d=$(( n > 1 ? n - 1 : 1 ))
292
+ while [ "$i" -lt "$n" ]; do
293
+ ch="${text:$i:1}"
294
+ r=$(( 226 - (58 * i / d) ))
295
+ g=$(( 70 - (19 * i / d) ))
296
+ b=$(( 42 - (14 * i / d) ))
297
+ printf '\033[38;2;%d;%d;%dm\033[1m%s' "$r" "$g" "$b" "$ch"
298
+ i=$((i + 1))
299
+ done
300
+ return 0
301
+ }
302
+
303
+ # wizard_progress_bar: print a fixed-width brand progress bar, accent-filled cells
304
+ # for the completed fraction and grey empty cells for the rest. It ports the bar in
305
+ # mac/docs/setup-wizard-mockup-bash.sh and reads the palette WZ_ACCENT and WZ_GREY,
306
+ # each with a set -u default so a direct caller before wizard_palette_init still
307
+ # runs. The fill glyph and the empty glyph are held in variables and appended as
308
+ # ${s}${glyph}, never as a glyph written right after a variable expansion, because
309
+ # bash 3.2.57 under set -u misparses a multibyte glyph placed directly after $var.
310
+ # The total is guarded to at least one so a zero total never divides by zero, even
311
+ # though every caller passes the fixed stage total. A single trailing reset closes
312
+ # the color, matching the stage header, which already emits escapes in its TTY branch.
313
+ wizard_progress_bar() {
314
+ local done="$1" total="$2" width=34 fill i s='' full='█' empty='░'
315
+ [ "$total" -gt 0 ] || total=1
316
+ fill=$(( width * done / total ))
317
+ i=0
318
+ while [ "$i" -lt "$width" ]; do
319
+ if [ "$i" -lt "$fill" ]; then
320
+ s="${s}${WZ_ACCENT:-}${full}"
321
+ else
322
+ s="${s}${WZ_GREY:-}${empty}"
323
+ fi
324
+ i=$((i + 1))
325
+ done
326
+ printf '%s\033[0m' "$s"
327
+ }
328
+
329
+ # wizard_line_h: print one character repeated width times, used for the horizontal
330
+ # rules inside the box borders. It holds the repeat character in a variable and
331
+ # appends it as ${s}${c}, never as a literal glyph placed directly after $s, because
332
+ # bash 3.2.57 under set -u misparses a multibyte glyph written right after a
333
+ # variable expansion as part of the variable name.
334
+ wizard_line_h() {
335
+ local w="$1" c="$2" s="" i=0
336
+ while [ "$i" -lt "$w" ]; do s="${s}${c}"; i=$((i + 1)); done
337
+ printf '%s' "$s"
338
+ }
339
+
340
+ # wizard_box_top / wizard_box_bot: draw the rounded top and bottom borders of the
341
+ # header box in e-ink. The box glyphs sit in the printf format string, not after a
342
+ # variable expansion, so the multibyte parse hazard does not apply here.
343
+ wizard_box_top() {
344
+ printf ' %s╭%s╮\033[0m\n' "${WZ_EINK:-}" "$(wizard_line_h "$1" "─")"
345
+ }
346
+
347
+ wizard_box_bot() {
348
+ printf ' %s╰%s╯\033[0m\n' "${WZ_EINK:-}" "$(wizard_line_h "$1" "─")"
349
+ }
350
+
351
+ # wizard_box_row: draw one content row of the header box. It takes the inner width,
352
+ # a plain copy of the text used only to size the right pad, and the styled text
353
+ # printed between the borders. It pads to the width with spaces so the right border
354
+ # lines up, and clamps a negative pad to zero when the text is wider than the box.
355
+ wizard_box_row() {
356
+ local w="$1" plain="$2" styled="$3" pad
357
+ pad=$(( w - 2 - ${#plain} )); [ "$pad" -lt 0 ] && pad=0
358
+ printf ' %s│\033[0m %s%s %s│\033[0m\n' "${WZ_EINK:-}" "$styled" "$(wizard_line_h "$pad" " ")" "${WZ_EINK:-}"
359
+ }
360
+
361
+ wizard_splash() {
362
+ [ "${GUIDED_TTY:-0}" = 1 ] || return 0
363
+ wizard_palette_init
364
+ local inner=54 row1 row2
365
+ # printf turns \033 into a real escape byte, so the captured rows carry real
366
+ # escapes for wizard_box_row to place between the borders. row1 is the gradient
367
+ # brand word, a reset to drop the gradient bold, then the paper tagline. The
368
+ # plain-copy arguments size the right pad and never reach the screen.
369
+ row1="$(wizard_gradient "Pairling"; printf '\033[0m%s Pair your iPhone with your coding agents' "$WZ_PAPER")"
370
+ row2="${WZ_PAPER} on this Mac."
371
+ wizard_box_top "$inner"
372
+ wizard_box_row "$inner" "Pairling Pair your iPhone with your coding agents" "$row1"
373
+ wizard_box_row "$inner" " on this Mac." "$row2"
374
+ wizard_box_bot "$inner"
375
+ if ! reduced_motion_on; then
376
+ local frames='⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏' f
377
+ for f in $frames; do
378
+ printf '\r %s%s\033[0m %sVerifying the staged runtime\033[0m' "$WZ_ACCENT" "$f" "$WZ_GREY"
379
+ sleep 0.08
380
+ done
381
+ printf '\r'
382
+ fi
383
+ if wizard_splash_verify; then
384
+ printf ' %sok\033[0m Runtime verified. \n' "$WZ_OK"
385
+ else
386
+ printf ' %sx\033[0m Runtime check failed. See the steps below.\n' "${WZ_ERR:-}"
387
+ fi
388
+ return 0
389
+ }
390
+
391
+ # safety_status_line: print the live SafetyMonitorBridge status as one parseable
392
+ # line. It runs as "$PYTHON3_BIN", the staged vendored python, and imports the
393
+ # bridge from the staged companiond, exactly as doctor.sh and pairlingd.py do. It
394
+ # constructs the bridge as SafetyMonitorBridge(APP_SUPPORT_ROOT, HOME), the same
395
+ # construction the daemon uses. It never reads a TCC store and never calls the
396
+ # safety HTTP routes, which need a device bearer token the local wizard does not
397
+ # have. The companiond dir and the app support root are passed as argv, so an
398
+ # install path with a space is passed whole. On any failure it prints
399
+ # installed=false, so the caller fails closed to the not-installed advisory.
400
+ safety_status_line() {
401
+ "$PYTHON3_BIN" - "$CURRENT_LINK/companiond" "$APP_SUPPORT" <<'PY' 2>/dev/null || printf 'installed=false full_disk_access=unknown\n'
402
+ import os
403
+ import sys
404
+ from pathlib import Path
405
+
406
+ companiond_dir = sys.argv[1]
407
+ app_support_root = Path(sys.argv[2])
408
+ sys.path.insert(0, companiond_dir)
409
+ try:
410
+ from safety_monitor import SafetyMonitorBridge
411
+ bridge = SafetyMonitorBridge(app_support_root, Path(os.path.expanduser("~")))
412
+ status = bridge.status()
413
+ installed = "true" if status.get("installed") else "false"
414
+ fda = str(status.get("full_disk_access") or "unknown")
415
+ except Exception:
416
+ installed, fda = "false", "unknown"
417
+ print("installed=%s full_disk_access=%s" % (installed, fda))
418
+ PY
419
+ }
420
+
421
+ # safety_status_installed: return 0 when the bridge reports installed true, 1
422
+ # when it reports installed false, and the not-installed advisory path handles
423
+ # both 1 and any read failure, which also prints installed=false. The reader is
424
+ # the single source of truth for whether the future PairlingSafety.app is present.
425
+ safety_status_installed() {
426
+ case "$(safety_status_line)" in
427
+ *"installed=true"*) return 0 ;;
428
+ *) return 1 ;;
429
+ esac
430
+ }
431
+
432
+ # stage_begin: print the numbered header for one guided stage. The TTY branch keeps
433
+ # the bold [N/total] prefix, pads the title to a fixed field so the bar aligns across
434
+ # stages, then draws the brand progress bar filled to the current stage over the
435
+ # total, so the bar fills as the flow advances. It calls wizard_palette_init, which is
436
+ # idempotent, so the bar colors exist even if a caller reaches here first. The plain
437
+ # GUIDED_TTY=0 branch is unchanged and stays byte-identical, so every machine path
438
+ # keeps the exact \n[N/total] Title\n form with no bar and no escape byte.
439
+ stage_begin() {
440
+ GUIDED_STAGE_N=$((GUIDED_STAGE_N + 1))
441
+ GUIDED_STAGE_CURRENT="$1"
442
+ if [ "$GUIDED_TTY" = 1 ]; then
443
+ wizard_palette_init
444
+ printf '\n\033[1m[%d/%d] %-30s\033[0m %s\n' \
445
+ "$GUIDED_STAGE_N" "$GUIDED_STAGE_TOTAL" "$1" \
446
+ "$(wizard_progress_bar "$GUIDED_STAGE_N" "$GUIDED_STAGE_TOTAL")"
447
+ else
448
+ printf '\n[%d/%d] %s\n' "$GUIDED_STAGE_N" "$GUIDED_STAGE_TOTAL" "$1"
449
+ fi
450
+ }
451
+
452
+ stage_ok() {
453
+ if [ "$GUIDED_TTY" = 1 ]; then
454
+ wizard_palette_init
455
+ printf ' %sok\033[0m %s\n' "$WZ_OK" "$1"
456
+ else
457
+ printf ' ok: %s\n' "$1"
458
+ fi
459
+ }
460
+
461
+ stage_skip() {
462
+ if [ "$GUIDED_TTY" = 1 ]; then
463
+ wizard_palette_init
464
+ printf ' %s--\033[0m %s\n' "$WZ_GREY" "$1"
465
+ else
466
+ printf ' skip: %s\n' "$1"
467
+ fi
468
+ }
469
+
470
+ stage_note() {
471
+ printf ' %s\n' "$1"
472
+ }
473
+
474
+ # wizard_recovery_menu: a plain bash recovery menu. It uses a plain blocking
475
+ # read with no timeout, which bash 3.2.57 supports, so it works without any
476
+ # python. The recoverable kind offers open Full Disk Access settings, skip, and
477
+ # quit and resume. The fatal kind, an integrity or signature failure,
478
+ # has no retry and no skip, which mirrors the install script that exits with no
479
+ # bypass on a hash mismatch or a code-signature failure. When stdin is not a
480
+ # terminal it prints the options and returns, so a headless or piped run never
481
+ # blocks. open_full_disk_access_pane is defined by the safety step and runs the
482
+ # bridge open_full_disk_access method.
483
+ wizard_recovery_menu() {
484
+ local kind="$1" stage="$2"
485
+ if [ "$kind" = "fatal" ]; then
486
+ stage_note "Pairling stopped to protect your Mac at the $stage step."
487
+ stage_note "A file did not match its signed checksum, so setup will not continue. There is no way to skip this check."
488
+ stage_note "Options: [1] reinstall from a verified copy [2] view logs [q] quit"
489
+ else
490
+ stage_note "Setup needs your help at the $stage step."
491
+ stage_note "Options: [o] open Full Disk Access settings [s] skip for now [q] quit and resume"
492
+ fi
493
+ # Off a terminal, print the options and return without blocking.
494
+ [ -t 0 ] || return 0
495
+ local choice=""
496
+ while :; do
497
+ printf ' Choose an option: '
498
+ read -r choice || return 0
499
+ case "$kind:$choice" in
500
+ # Retry was removed here because no caller loops on the menu return today.
501
+ # In guided_on_exit the process is already exiting, and in safety_step the
502
+ # evidence poll has already timed out before the menu shows, so an [r] key
503
+ # did nothing. When the future PairlingSafety.app makes the evidence poll
504
+ # live, add a real retry loop in safety_step with a distinct menu return
505
+ # code, then reinstate an [r] option that maps to it. An r keypress now
506
+ # falls through to the reprompt below, which is correct.
507
+ recoverable:o|recoverable:O) open_full_disk_access_pane ;;
508
+ recoverable:s|recoverable:S) stage_note "Skipped. You can grant it later and run pairling setup again."; return 0 ;;
509
+ *:q|*:Q) stage_note "Quitting. Run pairling setup again to resume right here."; return 0 ;;
510
+ fatal:1) stage_note "Reinstall Pairling from a verified copy, then run pairling setup again."; return 0 ;;
511
+ fatal:2) stage_note "The full details are in the setup log under the audit folder."; return 0 ;;
512
+ *) stage_note "Pick one of the listed options." ;;
513
+ esac
514
+ done
515
+ }
516
+
517
+ # safety_call_method: run one no-argument SafetyMonitorBridge method as the
518
+ # staged python and print whatever the method returns as a single status word.
519
+ # It imports the bridge from the staged companiond and constructs it the same
520
+ # way the daemon does. The method name is passed as argv, so the python source
521
+ # is fixed. It is used by request_safety_activation, open_full_disk_access_pane,
522
+ # and poll_evidence_test, so they all share one import path.
523
+ safety_call_method() {
524
+ local method="$1"
525
+ "$PYTHON3_BIN" - "$CURRENT_LINK/companiond" "$APP_SUPPORT" "$method" <<'PY' 2>/dev/null || printf 'error\n'
526
+ import os
527
+ import sys
528
+ from pathlib import Path
529
+
530
+ companiond_dir, app_support_root, method = sys.argv[1], Path(sys.argv[2]), sys.argv[3]
531
+ sys.path.insert(0, companiond_dir)
532
+ try:
533
+ from safety_monitor import SafetyMonitorBridge
534
+ bridge = SafetyMonitorBridge(app_support_root, Path(os.path.expanduser("~")))
535
+ result = getattr(bridge, method)()
536
+ # request_activation returns "state", run_evidence_test returns "status",
537
+ # open_full_disk_access returns "state". Print the first present one.
538
+ word = result.get("status") or result.get("state") or ("ok" if result.get("ok") else "error")
539
+ print(word)
540
+ except Exception:
541
+ print("error")
542
+ PY
543
+ }
544
+
545
+ # request_safety_activation: guide System Extension approval through the bridge.
546
+ # It only runs when the app is installed, so the bridge launches
547
+ # PairlingSafety.app --pairling-request-activation. The wizard never claims it
548
+ # installed the app.
549
+ request_safety_activation() {
550
+ local state
551
+ state="$(safety_call_method request_activation)"
552
+ if [ "$state" = "approval_requested" ]; then
553
+ stage_note "Approve Pairling Safety Monitor in System Settings, then come back here."
554
+ else
555
+ stage_note "Could not request Safety Monitor approval right now. You can approve it later in System Settings."
556
+ fi
557
+ }
558
+
559
+ # open_full_disk_access_pane: open the Full Disk Access settings page through the
560
+ # bridge open_full_disk_access method, which runs the open command for the
561
+ # Privacy_AllFiles anchor. The wizard does not change any privacy setting itself.
562
+ open_full_disk_access_pane() {
563
+ safety_call_method open_full_disk_access >/dev/null 2>&1 || true
564
+ stage_note "Turn on Full Disk Access for Pairling Safety Monitor, then come back here."
565
+ }
566
+
567
+ # poll_evidence_test: re-run the bridge run_evidence_test every two seconds until
568
+ # it reports passed or the time runs out. It uses sleep, not a fractional read,
569
+ # because bash 3.2.57 rejects a sub-second read timeout. It returns 0 on a passed
570
+ # result and 124 on timeout. A "limited" result means process evidence passed but
571
+ # file visibility still needs Full Disk Access, so the loop keeps waiting. The
572
+ # interval and timeout are overridable for tests. The step clamp keeps waited
573
+ # advancing even when the interval is 0, so an always-limited result cannot loop
574
+ # forever.
575
+ poll_evidence_test() {
576
+ local interval="${PAIRLING_SAFETY_POLL_INTERVAL:-2}"
577
+ local timeout="${PAIRLING_SAFETY_POLL_TIMEOUT:-300}"
578
+ local step="$interval"
579
+ [ "$step" -lt 1 ] && step=1
580
+ local waited=0 result
581
+ while [ "$waited" -le "$timeout" ]; do
582
+ result="$(safety_call_method run_evidence_test)"
583
+ if [ "$result" = "passed" ]; then
584
+ return 0
585
+ fi
586
+ [ "$waited" -ge "$timeout" ] && break
587
+ sleep "$interval"
588
+ waited=$((waited + step))
589
+ done
590
+ return 124
591
+ }
592
+
593
+ # safety_step: the one safety gate in v1. It reads the live SafetyMonitorBridge
594
+ # status. Today the app is not installed, so the bridge reports installed false,
595
+ # and this prints one plain advisory line that the Safety Monitor is a future
596
+ # feature and is not installed, and that pairing works without it, then continues.
597
+ # It never claims it installed the app. It never shows or blocks on Full Disk
598
+ # Access when the app is absent. When a future PairlingSafety.app reports
599
+ # installed true, the same flow guides System Extension approval, then Full Disk
600
+ # Access, then polls the evidence test until file evidence passes, advancing on
601
+ # pass or showing the recovery menu on timeout. It never blocks pairing and
602
+ # always returns 0.
603
+ safety_step() {
604
+ if safety_status_installed; then
605
+ stage_note "Pairling Safety Monitor is installed. Setting it up so it can watch your agent sessions."
606
+ request_safety_activation
607
+ open_full_disk_access_pane
608
+ stage_note "Checking that the Safety Monitor can see process and file evidence. We check every 2 seconds."
609
+ if poll_evidence_test; then
610
+ stage_note "The Safety Monitor sees full evidence. Thank you."
611
+ else
612
+ stage_note "The Safety Monitor did not reach full file evidence within the time limit."
613
+ # A skip never blocks pairing. File visibility is the only thing limited
614
+ # until Full Disk Access is granted.
615
+ wizard_recovery_menu recoverable "macOS permissions" || true
616
+ fi
617
+ else
618
+ # The not-installed advisory. This is today's path. It states the truth: the
619
+ # Safety Monitor is a future feature, it is not installed, and pairing works
620
+ # without it. It never claims setup installed anything.
621
+ stage_note "Pairling Safety Monitor is a future feature and is not installed yet. Pairing works without it."
622
+ fi
623
+ stage_note "On first pair your iPhone asks for Local Network access, so allow it. This Mac and the iPhone must be on the same Wi-Fi so the Mac can see the phone."
624
+ stage_note "If Local Network is allowed and pairing still stalls, the block is on the Mac or the network side, not the iPhone."
625
+ stage_note "Accessibility and Automation are only needed later if you enable typing into Terminal from the phone. Run pairling doctor --json to see the exact Mac grantee path before enabling it."
626
+ return 0
627
+ }
628
+
629
+ # guided_on_exit — fires on ANY premature exit during setup (a set -e abort or an
630
+ # explicit exit 1 in staging, service startup, the QR, or auth), so a failure
631
+ # always leaves a clear recovery path. Suppressed once setup sets
632
+ # GUIDED_COMPLETE=1, so a clean run prints nothing here.
633
+ guided_on_exit() {
634
+ local code=$?
635
+ if [ "$GUIDED_COMPLETE" != 1 ] && [ "$code" != 0 ]; then
636
+ if [ "${WIZARD_TUI:-0}" = 1 ]; then
637
+ # Show the bash recovery menu. The kind is recoverable by default. The
638
+ # caller sets WIZARD_FATAL=1 before a fatal integrity or signature exit, so
639
+ # the menu drops retry and skip to mirror this script's no-bypass behavior.
640
+ if [ "${WIZARD_FATAL:-0}" = 1 ]; then
641
+ wizard_recovery_menu fatal "${GUIDED_STAGE_CURRENT:-startup}" || true
642
+ else
643
+ wizard_recovery_menu recoverable "${GUIDED_STAGE_CURRENT:-startup}" || true
644
+ fi
645
+ fi
646
+ printf '\nSetup did not finish (stage: %s, exit %s).\n' "${GUIDED_STAGE_CURRENT:-startup}" "$code" >&2
647
+ printf 'Recovery: run `pairling doctor --json` to inspect, then re-run `pairling setup`.\n' >&2
648
+ printf 'Retry pairing only: `pairling pair --qr`. Sign in to Pairling Connect: `pairling connect-auth-open`.\n' >&2
649
+ fi
650
+ }
651
+
652
+ # guided_permission_notice — advisory only. Surfaces ONLY the permissions the
653
+ # code actually uses (verified against doctor.sh permission_readiness): the
654
+ # iPhone shows a Local Network prompt on first pair, and this Mac needs no
655
+ # privacy permission for basic pairing. It never reads or modifies any privacy
656
+ # setting and never blocks setup.
657
+ guided_permission_notice() {
658
+ stage_note "This Mac needs no special privacy permission to pair."
659
+ stage_note "On your iPhone allow Local Network access when Pairling asks. This Mac and the iPhone must be on the same Wi-Fi so the Mac can see the phone."
660
+ stage_note "If you already allowed Local Network on the iPhone and pairing still stalls, the block is on the Mac or the network, not the iPhone. Check that both devices are on the same Wi-Fi."
661
+ stage_note "Accessibility and Automation are only needed if you later enable typing into Terminal from the phone. Run pairling doctor --json to see the exact Mac grantee path before enabling it."
662
+ }
663
+
664
+ # guided_route_proof — one bounded, best-effort read of connectd /status that
665
+ # tells the user whether the remote Pairling Connect route is live yet. Local and
666
+ # LAN pairing already work regardless of the result. This never blocks or fails
667
+ # setup (the whole probe is wrapped in `|| true`).
668
+ guided_route_proof() {
669
+ python3 - "$REPO_ROOT" <<'PY' || true
670
+ import os
671
+ import sys
672
+
673
+ repo_root = sys.argv[1]
674
+ sys.path.insert(0, os.path.join(repo_root, "mac", "companiond"))
675
+ try:
676
+ from pairling_connectd_status import fetch_connectd_status, advertised_pairling_connect_routes
677
+ except Exception:
678
+ sys.exit(0)
679
+
680
+ status = fetch_connectd_status(timeout_seconds=0.7) or {}
681
+ try:
682
+ routes = advertised_pairling_connect_routes(status)
683
+ except Exception:
684
+ routes = []
685
+
686
+ if routes:
687
+ print(" Route check: the Pairling Connect remote route is ready.")
688
+ elif str(status.get("auth_state") or "") == "authenticated":
689
+ print(" Route check: Pairling Connect is signed in; the remote route will advertise shortly.")
690
+ else:
691
+ print(" Route check: local pairing is ready now; the remote route hardens after you sign in and the phone joins.")
692
+ PY
693
+ }
694
+
695
+ # guided_pairing_seen_proof is one bounded, best-effort, read-only check of the
696
+ # devices database that tells the user whether this Mac has recorded the iPhone
697
+ # finishing pairing in this session. It takes the session-start epoch as its
698
+ # first argument, so a device paired in an earlier run does not read as seen on a
699
+ # re-run. It opens the database read-only with mode=ro, so it never locks the
700
+ # file the daemon is writing, and it treats a missing or empty database or any
701
+ # sqlite error as "not seen". It polls up to about 6 seconds at 1 second steps
702
+ # and exits early the moment a matching device appears, so a scanned phone is
703
+ # confirmed within about a second and only an unscanned run waits the full
704
+ # window. The whole probe is wrapped in `|| true`, so it never blocks or fails
705
+ # setup.
706
+ guided_pairing_seen_proof() {
707
+ local since="${1:-0}"
708
+ PAIRLING_PAIRING_SEEN_POLL_STEPS="${PAIRLING_PAIRING_SEEN_POLL_STEPS:-6}" \
709
+ python3 - "$DEVICES_DB" "$since" <<'PY' || true
710
+ import os
711
+ import sqlite3
712
+ import sys
713
+ import time
714
+
715
+ db_path = sys.argv[1] if len(sys.argv) > 1 else ""
716
+ try:
717
+ since = float(sys.argv[2])
718
+ except (IndexError, ValueError):
719
+ since = 0.0
720
+ try:
721
+ steps = int(os.environ.get("PAIRLING_PAIRING_SEEN_POLL_STEPS") or "6")
722
+ except ValueError:
723
+ steps = 6
724
+ if steps < 1:
725
+ steps = 1
726
+
727
+ def count_session_devices():
728
+ # Open read-only with mode=ro so this never locks or creates the database the
729
+ # daemon is writing. Count only non-revoked rows created at or after the
730
+ # session start, so a device from an earlier run does not read as seen.
731
+ con = sqlite3.connect("file:" + db_path + "?mode=ro", uri=True, timeout=0.5)
732
+ try:
733
+ row = con.execute(
734
+ "SELECT COUNT(*) FROM devices WHERE revoked_at IS NULL AND created_at >= ?",
735
+ (since,),
736
+ ).fetchone()
737
+ return int(row[0]) if row else 0
738
+ finally:
739
+ con.close()
740
+
741
+ seen = 0
742
+ try:
743
+ for step in range(steps):
744
+ try:
745
+ seen = count_session_devices()
746
+ except Exception:
747
+ # A missing or empty database, or any sqlite error, means not seen.
748
+ seen = 0
749
+ if seen > 0:
750
+ break
751
+ if step < steps - 1:
752
+ time.sleep(1)
753
+ if seen > 0:
754
+ print(" Pairing check: this Mac saw your iPhone connect and finish pairing.")
755
+ else:
756
+ print(" Pairing check: this Mac has not recorded your iPhone finishing pairing yet.")
757
+ print(" If you just scanned the code, give it a moment and it should appear.")
758
+ print(" If it keeps stalling, confirm the iPhone allowed Local Network and both devices are on the same Wi-Fi.")
759
+ except Exception:
760
+ # Any unexpected error means not seen. Never raise, so setup continues.
761
+ pass
762
+ sys.exit(0)
763
+ PY
764
+ }
765
+
766
+ # guided_finish_summary — the success and recovery surface. It states the next
767
+ # device step, proves the route, and prints the exact re-run commands for any
768
+ # step the operator may need to repeat.
769
+ guided_finish_summary() {
770
+ stage_note "The pairing code is shown above. Open Pairling on your iPhone, scan it, then approve this Mac."
771
+ if ! is_dry_run; then
772
+ guided_route_proof || true
773
+ fi
774
+ if ! is_dry_run; then guided_pairing_seen_proof "${PAIRLING_PAIRING_STARTED_AT:-0}" || true; fi
775
+ stage_note "Inspect status anytime: pairling doctor --json"
776
+ stage_note "Re-show the pairing code: pairling pair --qr"
777
+ stage_note "Sign in for the remote route: pairling connect-auth-open"
778
+ }
779
+
103
780
  append_history() {
104
781
  local status="$1"
105
782
  local detail="$2"
@@ -165,8 +842,6 @@ run_compile_checks() {
165
842
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/external.py"
166
843
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/companiond/providers/registry.py"
167
844
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/mcp/phone_tools.py"
168
- PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/companion-power-guardian.py"
169
- PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/guardian/guardian_contract.py"
170
845
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/render-launchd.py"
171
846
  PYTHONPYCACHEPREFIX="$pycache_root" python3 -m py_compile "$REPO_ROOT/mac/install/psk_dependency_check.py"
172
847
  rm -rf "$pycache_root"
@@ -266,10 +941,43 @@ clear_release_quarantine() {
266
941
  fi
267
942
  }
268
943
 
944
+ ensure_pairdrop_folder() {
945
+ mkdir -p "$PAIRDROP_ROOT"
946
+ chmod 700 "$PAIRDROP_ROOT" 2>/dev/null || true
947
+ local probe="$PAIRDROP_ROOT/.pairling-write-test.$$"
948
+ if ! printf 'ok\n' > "$probe" 2>/dev/null; then
949
+ log "ERROR: PairDrop folder is not writable: $(display_path "$PAIRDROP_ROOT")" >&2
950
+ exit 1
951
+ fi
952
+ rm -f "$probe"
953
+ log "PairDrop folder: $(display_path "$PAIRDROP_ROOT")"
954
+ }
955
+
956
+ payload_manifest_path() {
957
+ local candidate="$REPO_ROOT/../payload-manifest.json"
958
+ if [[ -f "$candidate" ]]; then
959
+ printf '%s\n' "$candidate"
960
+ fi
961
+ }
962
+
963
+ verify_payload_manifest() {
964
+ local manifest
965
+ manifest="$(payload_manifest_path)"
966
+ if [[ -z "$manifest" ]]; then
967
+ return 0
968
+ fi
969
+ log "Verifying npm payload manifest"
970
+ if ! "$PYTHON3_BIN" "$REPO_ROOT/mac/install/verify-payload-manifest.py" "$REPO_ROOT" "$manifest"; then
971
+ WIZARD_FATAL=1
972
+ exit 1
973
+ fi
974
+ }
975
+
269
976
  copy_release() {
270
977
  local tmp="$RELEASE_ROOT.tmp"
271
978
  rm -rf "$tmp"
272
- mkdir -p "$tmp/bin" "$tmp/companiond" "$tmp/companiond/providers" "$tmp/companiond/integrations/aperture_cli" "$tmp/connectd" "$tmp/guardian" "$tmp/mac" "$tmp/mcp"
979
+ verify_payload_manifest
980
+ mkdir -p "$tmp/bin" "$tmp/companiond" "$tmp/companiond/providers" "$tmp/companiond/integrations/aperture_cli" "$tmp/connectd" "$tmp/mac" "$tmp/mcp"
273
981
  cp "$REPO_ROOT/mac/companiond/pairlingd.py" "$tmp/companiond/"
274
982
  cp "$REPO_ROOT/mac/companiond/runtime_contract.py" "$tmp/companiond/"
275
983
  cp "$REPO_ROOT/mac/companiond/runtime_manifest.py" "$tmp/companiond/"
@@ -303,19 +1011,17 @@ copy_release() {
303
1011
  cp "$REPO_ROOT/mac/companiond/integrations/aperture_cli/"*.py "$tmp/companiond/integrations/aperture_cli/"
304
1012
  cp "$REPO_ROOT/mac/companiond/providers/"*.py "$tmp/companiond/providers/"
305
1013
  cp "$REPO_ROOT/mac/mcp/phone_tools.py" "$tmp/mcp/"
306
- cp "$REPO_ROOT/mac/guardian/companion-power-guardian.py" "$tmp/guardian/"
307
- cp "$REPO_ROOT/mac/guardian/guardian_contract.py" "$tmp/guardian/"
308
1014
  build_connectd_binary "$tmp/connectd/pairling-connectd"
309
1015
  stage_vendored_python "$tmp/python"
310
1016
  run_staged_psk_dependency_checks "$tmp"
311
1017
  copy_runtime_source_tree "$tmp/mac" "$tmp/connectd/pairling-connectd"
312
1018
  write_installed_pairling_launcher "$tmp/bin/pairling"
313
- chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
1019
+ chmod 755 "$tmp/bin/pairling" "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py"
314
1020
  chmod 755 "$tmp/connectd/pairling-connectd"
315
- chmod 644 "$tmp/companiond/"*.py "$tmp/mcp/"*.py "$tmp/guardian/"*.py
1021
+ chmod 644 "$tmp/companiond/"*.py "$tmp/mcp/"*.py
316
1022
  chmod 644 "$tmp/companiond/providers/"*.py
317
1023
  chmod 644 "$tmp/companiond/integrations/"*.py "$tmp/companiond/integrations/aperture_cli/"*.py
318
- chmod 755 "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py" "$tmp/guardian/companion-power-guardian.py"
1024
+ chmod 755 "$tmp/companiond/pairlingd.py" "$tmp/mcp/phone_tools.py"
319
1025
  clear_release_quarantine "$tmp"
320
1026
  rm -rf "$RELEASE_ROOT"
321
1027
  mv "$tmp" "$RELEASE_ROOT"
@@ -330,7 +1036,6 @@ copy_runtime_source_tree() {
330
1036
  "$mac_root/companiond/providers" \
331
1037
  "$mac_root/companiond/integrations/aperture_cli" \
332
1038
  "$mac_root/connectd/bin" \
333
- "$mac_root/guardian" \
334
1039
  "$mac_root/install" \
335
1040
  "$mac_root/mcp" \
336
1041
  "$mac_root/packaging/bin"
@@ -351,7 +1056,6 @@ copy_runtime_source_tree() {
351
1056
  cp -R "$REPO_ROOT/mac/connectd/cmd" "$mac_root/connectd/"
352
1057
  cp -R "$REPO_ROOT/mac/connectd/internal" "$mac_root/connectd/"
353
1058
  cp "$connectd_binary" "$mac_root/connectd/bin/pairling-connectd"
354
- cp "$REPO_ROOT/mac/guardian/"*.py "$mac_root/guardian/"
355
1059
  cp "$REPO_ROOT/mac/install/"*.sh "$mac_root/install/"
356
1060
  cp "$REPO_ROOT/mac/install/"*.py "$mac_root/install/"
357
1061
  cp "$REPO_ROOT/mac/mcp/"*.py "$mac_root/mcp/"
@@ -396,12 +1100,14 @@ stage_vendored_python() {
396
1100
  # switch (-) disables that one check for local ad-hoc builds.
397
1101
  if ! /usr/bin/codesign --verify --strict "$src_tree/bin/python3" >/dev/null 2>&1; then
398
1102
  log "ERROR: vendored python failed codesign verification; refusing to stage: $src_tree/bin/python3" >&2
1103
+ WIZARD_FATAL=1
399
1104
  exit 1
400
1105
  fi
401
1106
  local team identifier
402
1107
  identifier="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^Identifier=//p')"
403
1108
  if [[ "$identifier" != "$PYTHON_CODESIGN_IDENTIFIER" ]]; then
404
1109
  log "ERROR: vendored python identifier '${identifier:-none}' is not '$PYTHON_CODESIGN_IDENTIFIER'; refusing to stage." >&2
1110
+ WIZARD_FATAL=1
405
1111
  exit 1
406
1112
  fi
407
1113
  if [[ "$required_team" == "-" ]]; then
@@ -410,6 +1116,7 @@ stage_vendored_python() {
410
1116
  team="$(/usr/bin/codesign -dvv "$src_tree/bin/python3" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
411
1117
  if [[ "$team" != "$required_team" ]]; then
412
1118
  log "ERROR: vendored python TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage." >&2
1119
+ WIZARD_FATAL=1
413
1120
  exit 1
414
1121
  fi
415
1122
  fi
@@ -441,12 +1148,14 @@ build_connectd_binary() {
441
1148
  else
442
1149
  if ! /usr/bin/codesign --verify --strict "$prebuilt_env" >/dev/null 2>&1; then
443
1150
  log "ERROR: connectd binary failed codesign verification; refusing to stage: $prebuilt_env" >&2
1151
+ WIZARD_FATAL=1
444
1152
  exit 1
445
1153
  fi
446
1154
  local team
447
1155
  team="$(/usr/bin/codesign -dvv "$prebuilt_env" 2>&1 | sed -n 's/^TeamIdentifier=//p')"
448
1156
  if [[ "$team" != "$required_team" ]]; then
449
1157
  log "ERROR: connectd binary TeamIdentifier '${team:-none}' does not match required '$required_team'; refusing to stage: $prebuilt_env" >&2
1158
+ WIZARD_FATAL=1
450
1159
  exit 1
451
1160
  fi
452
1161
  fi
@@ -536,8 +1245,6 @@ for rel in [
536
1245
  "companiond/providers/registry.py",
537
1246
  "connectd/pairling-connectd",
538
1247
  "mcp/phone_tools.py",
539
- "guardian/companion-power-guardian.py",
540
- "guardian/guardian_contract.py",
541
1248
  ]:
542
1249
  path = root / rel
543
1250
  digest = hashlib.sha256(path.read_bytes()).hexdigest()
@@ -567,13 +1274,11 @@ manifest = {
567
1274
  "daemon_label": "dev.pairling.companiond",
568
1275
  "ptybroker_label": "dev.pairling.ptybroker",
569
1276
  "connectd_label": "dev.pairling.connectd",
570
- "guardian_label": "dev.pairling.power-guardian",
571
1277
  },
572
1278
  "paths": {
573
1279
  "app_support": app_support,
574
1280
  "logs": logs_root,
575
1281
  "pair_records": str(Path(app_support) / "pair"),
576
- "guardian_state": "/var/run/pairling-power-state.json",
577
1282
  },
578
1283
  "migration": {
579
1284
  "legacy_port": 7723,
@@ -702,7 +1407,6 @@ render_plists() {
702
1407
  --logs-root "$LOGS_ROOT"
703
1408
  --output-dir "$PLIST_BUILD_DIR"
704
1409
  --daemon-python "$daemon_python"
705
- --guardian-python "$GUARDIAN_PYTHON_BIN"
706
1410
  )
707
1411
  python3 "$REPO_ROOT/mac/install/render-launchd.py" "${render_args[@]}"
708
1412
  }
@@ -715,6 +1419,7 @@ start_user_agent() {
715
1419
  log "dry-run: rendered $USER_PLIST"
716
1420
  return
717
1421
  fi
1422
+ if launchd_skipped; then return 0; fi
718
1423
  launchctl bootout "gui/$(id -u)" "$USER_PLIST" >/dev/null 2>&1 || true
719
1424
  launchctl bootstrap "gui/$(id -u)" "$USER_PLIST" >/dev/null 2>&1 || true
720
1425
  launchctl kickstart -k "gui/$(id -u)/$PAIRLING_DAEMON_LABEL"
@@ -728,6 +1433,7 @@ start_connectd_agent() {
728
1433
  log "dry-run: rendered $CONNECTD_USER_PLIST"
729
1434
  return
730
1435
  fi
1436
+ if launchd_skipped; then return 0; fi
731
1437
  launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
732
1438
  launchctl bootstrap "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
733
1439
  launchctl kickstart -k "gui/$(id -u)/$PAIRLING_CONNECTD_LABEL"
@@ -988,6 +1694,7 @@ ensure_ptybroker_agent() {
988
1694
  fi
989
1695
  return
990
1696
  fi
1697
+ if launchd_skipped; then return 0; fi
991
1698
  if ! launchctl print "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1; then
992
1699
  launchctl bootstrap "gui/$(id -u)" "$PTYBROKER_USER_PLIST" >/dev/null 2>&1 || true
993
1700
  launchctl kickstart "gui/$(id -u)/$PAIRLING_PTYBROKER_LABEL" >/dev/null 2>&1 || true
@@ -1069,28 +1776,6 @@ stop_connectd_agent() {
1069
1776
  launchctl bootout "gui/$(id -u)" "$CONNECTD_USER_PLIST" >/dev/null 2>&1 || true
1070
1777
  }
1071
1778
 
1072
- install_guardian_if_possible() {
1073
- local rendered="$PLIST_BUILD_DIR/$PAIRLING_GUARDIAN_LABEL.plist"
1074
- if [[ "${PAIRLING_INSTALL_GUARDIAN:-0}" != "1" ]]; then
1075
- log "Optional power guardian not installed; pairing can continue without the privileged sleep helper."
1076
- return
1077
- fi
1078
- if is_dry_run; then
1079
- log "dry-run: would install $PAIRLING_GUARDIAN_LABEL"
1080
- return
1081
- fi
1082
- if sudo -n true >/dev/null 2>&1; then
1083
- sudo cp "$rendered" "$SYSTEM_PLIST"
1084
- sudo chown root:wheel "$SYSTEM_PLIST"
1085
- sudo chmod 644 "$SYSTEM_PLIST"
1086
- sudo launchctl bootout system "$SYSTEM_PLIST" >/dev/null 2>&1 || true
1087
- sudo launchctl bootstrap system "$SYSTEM_PLIST" >/dev/null 2>&1 || true
1088
- sudo launchctl kickstart -k "system/$PAIRLING_GUARDIAN_LABEL"
1089
- else
1090
- log "Skipping guardian install: passwordless sudo is unavailable. Re-run with privileges when ready."
1091
- fi
1092
- }
1093
-
1094
1779
  run_doctor() {
1095
1780
  "$REPO_ROOT/mac/install/doctor.sh"
1096
1781
  }
@@ -1118,6 +1803,41 @@ rollback() {
1118
1803
  }
1119
1804
 
1120
1805
  install_runtime() {
1806
+ local setup_args=("$@")
1807
+ # Guard the empty-array expansion: install_runtime is called with no args
1808
+ # today, and under bash 3.2 with set -u "${setup_args[@]}" raises an unbound
1809
+ # variable error when the array is empty. The length check avoids that.
1810
+ # The first-run flow pipes our stdout through tee, so want_tui fails its
1811
+ # [ -t 1 ] check even with a real terminal present. When PAIRLING_WIZARD is set,
1812
+ # want_tui_tty re-enables the screen, but only when a /dev/tty write probe
1813
+ # succeeds and no machine condition blocks it, so NO_COLOR, CI, dry-run, --json,
1814
+ # --plan-only, and a dumb TERM still disable it on the first-run path too.
1815
+ if [ "${#setup_args[@]}" -gt 0 ]; then
1816
+ { want_tui "${setup_args[@]:-}" || { [ "${PAIRLING_WIZARD:-0}" = 1 ] && want_tui_tty "${setup_args[@]:-}"; }; } \
1817
+ && [ -x "$PYTHON3_BIN" ] && WIZARD_TUI=1 || WIZARD_TUI=0
1818
+ else
1819
+ { want_tui || { [ "${PAIRLING_WIZARD:-0}" = 1 ] && want_tui_tty; }; } \
1820
+ && [ -x "$PYTHON3_BIN" ] && WIZARD_TUI=1 || WIZARD_TUI=0
1821
+ fi
1822
+ # The stage color and the splash follow WIZARD_TUI, not raw stdout, because the
1823
+ # first-run flow pipes our stdout through tee to the terminal, so [ -t 1 ] is
1824
+ # false there even with a real terminal. A machine path keeps WIZARD_TUI 0, so
1825
+ # it stays plain and byte-stable. GUIDED_TTY follows WIZARD_TUI except when
1826
+ # PAIRLING_GUIDED_PLAIN forces the plain form, which keeps the user opt-out the
1827
+ # load-time GUIDED_TTY honors. The knob is unset on the first-run path, so
1828
+ # first-run rendering is unaffected.
1829
+ if [ "$WIZARD_TUI" = 1 ] && [ "${PAIRLING_GUIDED_PLAIN:-0}" != "1" ]; then GUIDED_TTY=1; else GUIDED_TTY=0; fi
1830
+ # Load the brand palette exactly when the guided screen turns on. It sits on its
1831
+ # own line, not inside the gate above, because a contract test extracts that gate
1832
+ # by its literal one-line form. The call is idempotent, so the later defensive
1833
+ # calls in wizard_splash and the stage markers cost only one string test.
1834
+ if [ "$GUIDED_TTY" = 1 ]; then wizard_palette_init; fi
1835
+ if is_dry_run; then GUIDED_STAGE_TOTAL=5; else GUIDED_STAGE_TOTAL=8; fi
1836
+ trap guided_on_exit EXIT
1837
+ # When WIZARD_TUI is 1 the guided stages add the splash, the live safety step,
1838
+ # and the bash recovery menu, all behind a WIZARD_TUI check and the dry-run
1839
+ # guard. When it is 0 the existing plain printf flow runs unchanged.
1840
+ setup_intro
1121
1841
  log "Pairling setup preview:"
1122
1842
  log " app support: $(display_path "$APP_SUPPORT")"
1123
1843
  log " logs: $(display_path "$LOGS_ROOT")"
@@ -1125,18 +1845,31 @@ install_runtime() {
1125
1845
  log " PTY Broker LaunchAgent: $PAIRLING_PTYBROKER_LABEL"
1126
1846
  log " Connect LaunchAgent: $PAIRLING_CONNECTD_LABEL"
1127
1847
  log " runtime port: $PAIRLING_RUNTIME_PORT"
1848
+
1849
+ stage_begin "Preparing the Mac runtime"
1128
1850
  run_compile_checks
1129
1851
  run_psk_dependency_checks
1130
1852
  ensure_state
1853
+ stage_ok "checks passed and state is ready"
1854
+
1855
+ stage_begin "PairDrop folder"
1856
+ ensure_pairdrop_folder
1857
+ stage_ok "$(display_path "$PAIRDROP_ROOT") is ready (private, mode 0700)"
1858
+
1859
+ stage_begin "Staging runtime"
1131
1860
  copy_release
1132
1861
  switch_current
1133
1862
  install_mcp_adapter_shim
1134
1863
  install_shell_wrapper
1864
+ stage_ok "staged $RELEASE_NAME"
1865
+
1866
+ stage_begin "Starting Pairling services"
1135
1867
  render_plists
1136
1868
  ensure_ptybroker_agent
1137
1869
  start_user_agent
1138
1870
  start_connectd_agent
1139
- install_guardian_if_possible
1871
+ stage_ok "companiond, connectd, and ptybroker are running"
1872
+
1140
1873
  append_history "installed" "installed $RELEASE_NAME"
1141
1874
  if is_dry_run; then
1142
1875
  log "dry-run: skipping doctor gate"
@@ -1144,17 +1877,46 @@ install_runtime() {
1144
1877
  run_doctor || true
1145
1878
  fi
1146
1879
  log "Installed Pairling runtime $RELEASE_NAME"
1880
+
1881
+ stage_begin "macOS permissions"
1882
+ if [ "${WIZARD_TUI:-0}" = 1 ] && ! is_dry_run; then
1883
+ # The safety step reads the live SafetyMonitorBridge status. Today it reports
1884
+ # not installed, so it prints one advisory line and continues. When a future
1885
+ # PairlingSafety.app is installed, it guides approval, Full Disk Access, and
1886
+ # the evidence test. It never blocks pairing. The plain advisory notice is the
1887
+ # WIZARD_TUI=0 fallback.
1888
+ safety_step
1889
+ else
1890
+ guided_permission_notice
1891
+ fi
1892
+ stage_ok "no Mac privacy permission is required to pair"
1893
+
1147
1894
  if ! is_dry_run; then
1148
- # Kick off the Tailscale sign-in before the QR so the pairing code can
1149
- # advertise the now-ready Pairling Connect tailnet route instead of
1150
- # downgrading to LAN/Bonjour. Never blocks or fails setup.
1151
- auto_open_connect_auth
1152
- log ""
1153
- if ! PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS="${PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS:-35}" pair_runtime --qr; then
1895
+ stage_begin "Pairing code for the iPhone"
1896
+ if [ "${WIZARD_TUI:-0}" = 1 ]; then
1897
+ stage_note "Open Pairling on your iPhone and scan this code. The pair address is printed below it too."
1898
+ fi
1899
+ # Record when this pairing attempt started, so the seen probe in
1900
+ # guided_finish_summary counts only a device paired during this session.
1901
+ export PAIRLING_PAIRING_STARTED_AT="$(python3 -c 'import time;print(time.time())')"
1902
+ if ! PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS="${PAIRLING_CONNECTD_ROUTE_WAIT_SECONDS:-0}" pair_runtime --qr; then
1154
1903
  log "Pairling installed, but setup could not generate a pairing invitation. Run: pairling doctor --json; pairling pair --qr" >&2
1155
1904
  exit 1
1156
1905
  fi
1906
+ stage_ok "pairing code displayed (local-first)"
1907
+ log ""
1908
+ # Browser auth is useful, but it must not block or precede the first pairing
1909
+ # code. First-pair bootstrap is local-first; Pairling Connect hardens after
1910
+ # the phone has joined.
1911
+ stage_begin "Pairling Connect sign-in (Mac)"
1912
+ auto_open_connect_auth
1913
+ stage_ok "Pairling Connect sign-in handled"
1914
+
1915
+ stage_begin "Finish and next steps"
1916
+ guided_finish_summary
1917
+ stage_ok "setup complete"
1157
1918
  fi
1919
+ GUIDED_COMPLETE=1
1158
1920
  }
1159
1921
 
1160
1922
  status_runtime() {
@@ -1366,10 +2128,11 @@ def default_pair_route(port_number: int) -> dict:
1366
2128
  value = os.environ.get(key)
1367
2129
  if value:
1368
2130
  return {"base_url": value, "source": "explicit_override", "status": "override"}
1369
- # Remote-first pairing: if connectd reports a ready Pairling Connect route,
1370
- # the QR advertises that route and the iOS app claims it through the
1371
- # embedded pre-pair transport. LAN/Bonjour are explicit degraded fallbacks
1372
- # when Pairling Connect is not ready.
2131
+ # First-pair bootstrap: prefer LAN when it exists. iOS blocks plain HTTP to
2132
+ # a tailnet IP before the embedded Pairling Connect route is ready.
2133
+ lan_ip = detected_lan_ip()
2134
+ if lan_ip:
2135
+ return {"base_url": f"http://{lan_ip}:{port_number}", "source": "lan", "status": "fallback", "kind": "lan"}
1373
2136
  route = ready_connectd_route()
1374
2137
  if route:
1375
2138
  return {
@@ -1378,9 +2141,6 @@ def default_pair_route(port_number: int) -> dict:
1378
2141
  "status": route["status"],
1379
2142
  "kind": route["kind"],
1380
2143
  }
1381
- lan_ip = detected_lan_ip()
1382
- if lan_ip:
1383
- return {"base_url": f"http://{lan_ip}:{port_number}", "source": "lan", "status": "fallback", "kind": "lan"}
1384
2144
  if os.environ.get("PAIRLING_DISABLE_BONJOUR") != "1" and os.environ.get("PAIRLING_TEST_DISABLE_BONJOUR") != "1":
1385
2145
  return {"base_url": f"http://{socket.gethostname()}.local:{port_number}", "source": "bonjour", "status": "fallback", "kind": "bonjour"}
1386
2146
  tailnet_ip = detected_tailnet_ip()
@@ -1612,7 +2372,7 @@ connect_auth_open() {
1612
2372
  exit 1
1613
2373
  }
1614
2374
 
1615
- # auto_open_connect_auth — kicked off by install_runtime BEFORE the pairing QR.
2375
+ # auto_open_connect_auth — kicked off by install_runtime after the first QR.
1616
2376
  # Polls connectd /status for readiness (reusing fetch_connectd_status, the same
1617
2377
  # helper pair_runtime imports). When connectd is in interactive mode and not yet
1618
2378
  # authenticated (auth_url_present == true AND auth_state != "authenticated"), it
@@ -1826,7 +2586,7 @@ case "$cmd" in
1826
2586
  shift
1827
2587
  "$REPO_ROOT/mac/install/bootstrap-first-run.sh" "$@"
1828
2588
  else
1829
- install_runtime
2589
+ install_runtime "$@"
1830
2590
  fi
1831
2591
  ;;
1832
2592
  first-run)