Compare commits

...
Author SHA1 Message Date
lawrencecchen 37a0c13319 Support image paste through mosh sessions 2026-08-07 03:28:47 -07:00
lawrencecchen e1648e7f31 test: cover mosh image paste destination detection 2026-08-07 03:28:47 -07:00
Lawrence Chen 3faf79585c Fix install analytics determinism gate (#9799) 2026-08-07 00:50:30 -07:00
Lawrence Chen 7daa9c6094 Fix copied surface link live identity (#9786)
* Add surface link live identity regression

* Use live surface identity for copied links
2026-08-07 00:25:11 -07:00
Lawrence Chen 99b7b370fe Harden install analytics privacy (#9791) 2026-08-06 23:33:18 -07:00
Lawrence Chen 0676a4cdac Serve coderouter landing on cmux.com (#9790) 2026-08-06 23:29:30 -07:00
Austin Wang c5bf3ca465 Merge pull request #9765 from manaflow-ai/issue-9746-horizontal-tab-insert
Fix horizontal tab strip insertion positions
2026-08-06 23:03:00 -07:00
Lawrence Chen 21675cc273 Stream raw PTY bytes to smart terminal clients (#9634)
* Merge main into raw PTY streaming

* Test smart stream byte-budget overflow

* test(tui): require raw protocol 11 compatibility boundary

* Bump private mux protocol to 11

* test(mac): cover selection and resize recovery regressions

* Recover terminal selections and resize delivery

* test(mac): bound resize acknowledgement retries

* fix(mac): bound resize acknowledgement retries

* test(sdk): cover protocol 11 inventory

* test(cpp): cover protocol 11 terminal placement

* Fix demo launcher and input readiness

* Bound C string snapshot retries

* Harden terminal snapshot and retry state

* Bound terminal snapshot consumers and retry state

* Address terminal review findings

* Render terminal frames from dirty rows

* Harden dirty frame lifecycle and retry pacing

* Apply terminal row deltas without full frame copies

* Add authoritative viewport row counts

* Initialize dirty renderer cache on empty frames
2026-08-06 22:51:52 -07:00
Lawrence Chen 371edf3c32 Add tracked website installers and coderouter landing (#9784) 2026-08-06 22:50:37 -07:00
Austin Wang c0b8391865 Merge pull request #9780 from manaflow-ai/issue-9768-codex-hook-dedup
Fix duplicate Codex hook channels and watchdog leaks
2026-08-06 22:43:20 -07:00
austinpower1258 6b79b62d9a fix: reconcile Codex hook producers 2026-08-06 22:39:47 -07:00
Lawrence Chen 42715b7f9e Isolate app-host tests from runner user config (#9716)
* test: require isolated app-host user state

* ci: isolate app-host user configuration

* test(ci): scope app-host isolation guard to jobs

* test(ci): reject malformed isolation workflow

* test: require app-host launch home isolation

* test: reject app-host config path leaks

* fix: isolate launched app-host user state

* test: require nonempty app-host XDG default

* fix: default app-host XDG path safely

* test: keep app-host redirects off xcodebuild

* fix: scope redirects to launched app host

* test: require XCTest runner home isolation

* fix: isolate XCTest app host through runner environment

* test: close app-host isolation validation gaps

* fix: harden app-host isolation validation

* test: cover optional app-host isolation validation

* fix: keep app-host isolation assertions opt in

* test: reject app-host XDG and scheme overrides

* fix: bind app-host XDG to isolated home

* test: cover non-isolated macOS Bash wrapper

* fix: support optional isolation on macOS Bash

* test: cover app-host child isolation boundaries

* fix: isolate app-host child process state

* test: require fail-closed app-host cleanup

* fix: fail closed and clean app-host homes

* test: require cleanup after XDG removal

* fix: clean homes after XDG removal

* style: restore process helper indentation

* test: require robust app-host teardown discovery

* fix: make app-host teardown robust

* test: preserve long-path app-host fixture

* test: keep app-host isolation out of driver environment

* fix: isolate app-host redirects at launch boundary

* test: reject ambient Ghostty configuration reads

* fix: validate every Ghostty config path

* test: clean mutated app-host XDG entries

* fix: clean mutated app-host XDG entries

* test: require durable app-host isolation evidence

* fix: require durable app-host isolation evidence

* test: enforce short symlink-safe app-host homes

* fix: use short symlink-safe app-host homes

* test: exercise app-host home symlink guard

* test: require app-host identity receipts

* fix: bind app-host cleanup to run identity

* test: keep app-host receipts across CI jobs

* fix: persist app-host process authority

* test: inject lsof into app-host retry guard

* test: preserve existing app-host authority scope

* fix: preserve existing app-host authority scope

* test: model dead-pid lsof diagnostics

* fix: classify dead-pid lsof diagnostics as stale

* test: authenticate cleanup before ownership transfer

* fix: authenticate app-host scope before ownership transfer

* test: bind app-host receipts to process incarnation

* fix: bind app-host receipts to process incarnation

* test: make app-host owner fixture portable

* test: require atomic app-host receipt publication

* fix: publish app-host receipts atomically

* test: cover app-host recovery review gaps

* fix: harden app-host recovery

* test: cover app-host ownership lifecycle gaps

* fix: scope app-host recovery to one run

* test: recover authenticated prior-run app hosts

* test: cover multiple current app-host owners

* test: bound prior app-host recovery eligibility

* test: model lsof machine descriptor fields

* test: recover deleted prior app-host owners

* fix: recover authenticated prior app hosts

* test: model lsof access mode fields

* fix: verify lsof receipt access mode

* test: include lsof access mode in cleanup fixture

* test: reject untrusted newest confirmations

* fix: authenticate newest app-host confirmations

* test: cover app-host machine ownership boundaries

* fix: bind app-host recovery to machine ownership
2026-08-06 22:21:47 -07:00
Austin Wang ce41f152de Merge pull request #9066 from manaflow-ai/issue-9065-remove-cgwindowlistcreateimage
Replace legacy window screenshot capture
2026-08-06 22:05:19 -07:00
austinpower1258 a26cf6d570 test: cover Codex hook producer deduplication 2026-08-06 22:01:13 -07:00
Austin Wang 82454e9bdb Merge pull request #9736 from manaflow-ai/issue-9128-pdf-share-button
fix: restore embedded PDF preview sharing
2026-08-06 21:48:18 -07:00
austinpower1258 722cae8562 fix: harden horizontal tab drop fallback 2026-08-06 21:47:32 -07:00
Austin Wang fecf416d50 Merge pull request #9744 from manaflow-ai/issue-9341-downloads-popover-contrast
Fix browser popover appearance contrast
2026-08-06 21:45:48 -07:00
Abdulaziz Albahar 9d8d66b10e Show iOS terminal send progress and failures (#9723)
* test(ios): cover terminal send status

* feat(ios): show terminal send status

* fix(ios): bind send status to queued input

* test(ios): reject persistent send success glyph

* fix(ios): clear send progress after delivery

* test(ios): preserve failed send across draft restore

* fix(ios): retain failed send after draft restore
2026-08-06 21:35:57 -07:00
austinpower1258 0e359517f1 fix: enable horizontal tab insertion positions 2026-08-06 21:35:20 -07:00
Lawrence Chen 5fcdb66a61 Revoke team route tokens when billing lapses (#9761)
* Honor Team subscriptions for hosted coderouter

* Revoke team route tokens when billing lapses
2026-08-06 21:29:45 -07:00
Abdulaziz Albahar 29d809d7d5 Replace iOS Mac hide swipes with row toggles (#9727)
* Replace iOS computer hide swipes with toggles

* Avoid storing visibility mutation tasks

* Keep visibility toggle actions synchronous

* Keep toggle binding actor-local

* Test visibility mutations preserve the latest intent

* Serialize iOS computer visibility mutations

* Animate iOS computer visibility toggles

* Test visibility mutations across account boundaries

* Cancel visibility mutations at scope boundaries

* Test cancelled visibility marker rollback

* Roll back cancelled visibility markers

* Test visibility queue tails across boundaries

* Preserve visibility serialization across scope changes
2026-08-06 21:27:55 -07:00
Abdulaziz Albahar 3347fd9a90 iOS browser stream: focus editables under replayed taps, suppress stray backspace, verbose diagnostics (#9729)
* test: a replayed phone click on a text field must focus it

Replayed clicks reach the streamed page as DOM events, but WebKit
refuses to move field focus for clicks in a window that is never key
(the offscreen render host). A tapped text field never focuses, the
phone keyboard never rises, and backspace falls through as page-level
history back-navigation instead of deleting. Red test plus the bare
panel replay seam the fix will hang off.

* Focus editables under replayed phone clicks; verbose browser-stream diagnostics

Programmatic JS focus is exempt from WebKit's key-window rule, so a
replayed click now hit-tests the tap point (descending one shadow-root
level) and focuses the editable it finds. The phone keyboard rises via
the existing editable_focused beacon, and typing lands in the field. A
bare backspace with no focused editable is suppressed instead of
falling through as WebKit history back-navigation, which lost page
state when users tried to delete text.

Verbose browser-stream diagnostics for debugging user reports: the Mac
host ring (Sentry-attached) records stream lifecycle (start, replace,
stop, first frame), input replay outcomes (kind, click count,
suppressed backspace, text length), focus-assist results, beacon
editable transitions, and create resolutions; the phone debug log
gains browser.create and browser.stream lifecycle lines.

* Address review: semantic diagnostic decodes, first-frame after delivery, shadow-aware focus checks

The four browser diagnostic codes now decode into named fields (stage,
input kind, count, focus outcome, panel correlation) instead of falling
through to detail_1/2/3, with a suppressed backspace becoming its own
input kind so counts stay unambiguous. The first-frame lifecycle stage
records once per session after the first successful browser.frame
delivery, never for a capture whose send failed. Editable-focus
detection descends shadow roots in the suppression check, the focus
assist's already-focused check, and the beacon, so widget-wrapped
inputs receive backspace and raise the keyboard; regression test
covers backspace delivery to a shadow-root input.
2026-08-06 21:26:12 -07:00
Abdulaziz Albahar 6277fdb408 Filter Iroh discovery by runtime scope (#9535)
* Add scoped connectivity discovery

* Harden scoped discovery fallbacks

* Validate discovery tags before hashing

* Keep discovery tag mapping off MainActor

* Test empty-route host registration renewal

* Renew empty-route Iroh host bindings

* Update host renewal deadline expectation

* Test scoped discovery review regressions

* Fix scoped discovery review regressions

* Test canonical peer tags and renewal deadlines

* Canonicalize scoped peer tag matching

* Satisfy scoped discovery review policy
2026-08-06 21:19:14 -07:00
Lawrence Chen 80054fcaca Honor Team subscriptions for hosted coderouter (#9753) 2026-08-06 21:18:04 -07:00
Austin Wang 10013687b0 Merge pull request #8563 from mcorcelle/feat/configurable-pane-flash-color
Make pane flash and attention ring color configurable
2026-08-06 21:08:04 -07:00
austinpower1258 3a205be7a2 fix: require attached PDF share anchor 2026-08-06 20:59:24 -07:00
Abdulaziz Albahar 8fa42e5e23 Open Notifications as a pane tab (#9721)
Notifications now opens as a normal workspace pane tab instead of a window-level overlay. Legacy overlay snapshots migrate to tabs, and background notification panes cannot steal terminal focus.

Verified with tagged cloud build npbg and focused hosted UI coverage.
2026-08-06 20:55:14 -07:00
austinpower1258 4f965d05ae test: mount canvas attention harness in window 2026-08-06 20:53:40 -07:00
austinpower1258 3a914b106d test: cover detached PDF share controls 2026-08-06 20:44:59 -07:00
Austin Wang 981d4d7816 Refresh appearance probe after SwiftUI updates 2026-08-06 20:36:04 -07:00
austinpower1258 bfe48260b6 fix: keep pane attention color owner-controlled 2026-08-06 20:35:18 -07:00
Austin Wang 6239b29a98 Make downloads appearance probe lifecycle-driven 2026-08-06 20:31:24 -07:00
Austin Wang 2c7bc82428 Move downloads appearance probe to debug support 2026-08-06 20:24:27 -07:00
austinpower1258 27a5086147 fix: update PDF share debug closure 2026-08-06 20:23:19 -07:00
austinpower1258 52c5f92251 fix: route PDF share activation explicitly 2026-08-06 20:16:34 -07:00
cmux reload-cloud 93298252ad fix: preserve efficient screenshot composition 2026-08-06 20:13:53 -07:00
Austin Wang 7857841837 Fix browser popover appearance resolution 2026-08-06 20:12:29 -07:00
austinpower1258 636ae73463 test: mount terminal attention ownership harness 2026-08-06 20:10:38 -07:00
austinpower1258 24d864951f test: mount PDF share controls before activation 2026-08-06 20:04:19 -07:00
cmux reload-cloud 76a1d08c0b test: align OMP restore environment contract 2026-08-06 19:55:48 -07:00
austinpower1258 11efc10f54 test: protect terminal attention color ownership 2026-08-06 19:52:04 -07:00
Austin Wang 189ac5b27b test: cover downloads popover contrast mismatch 2026-08-06 19:49:48 -07:00
austinpower1258 3a8f1156a6 Merge remote-tracking branch 'origin/main' into issue-9128-pdf-share-button 2026-08-06 19:40:50 -07:00
austinpower1258 1c4bd8c319 fix: complete PDF share translations 2026-08-06 19:40:33 -07:00
austinpower1258 4540f16c5b test: avoid PDF share helper shadowing 2026-08-06 19:39:01 -07:00
austinpower1258 9117d49b91 refactor: keep canvas color wiring at existing owners 2026-08-06 19:32:10 -07:00
austinpower1258 59dcd20a86 fix: support non-pointer PDF sharing 2026-08-06 19:21:58 -07:00
austinpower1258 3821e90345 fix: propagate pane attention color into canvas hosts 2026-08-06 19:16:37 -07:00
austinpower1258 b49eb593aa fix: present PDF share picker from chrome 2026-08-06 19:07:51 -07:00
cmux reload-cloud bbbf26c5e6 Merge remote-tracking branch 'origin/main' into issue-9065-remove-cgwindowlistcreateimage 2026-08-06 19:07:36 -07:00
cmux reload-cloud 8d4788a898 fix: isolate screenshot backend liveness 2026-08-06 19:07:16 -07:00
austinpower1258 86b33c919d perf: update pane color without rebuilding geometry 2026-08-06 18:54:42 -07:00
cmux reload-cloud ea43159b2d refactor: give screenshot capture one lifetime lease 2026-08-06 18:48:05 -07:00
Lawrence Chen f4263ae8b9 Restrict coderouter sign-in to passwordless email (#9740) 2026-08-06 18:43:53 -07:00
austinpower1258 098b4ad007 fix: make pane attention copy color-neutral 2026-08-06 18:40:31 -07:00
Lawrence Chen 87fb5db1b7 Delete completed reconciliation migration endpoint (#9739) 2026-08-06 18:40:26 -07:00
austinpower1258 8f8eb05a58 fix: continue parsing after invalid notification sound 2026-08-06 18:40:24 -07:00
austinpower1258 d6e8d30ef6 test: reproduce PDF preview share routing gap 2026-08-06 18:30:39 -07:00
cmux reload-cloud ac8082c81d fix: recover and clip screenshot compositing 2026-08-06 18:21:12 -07:00
Lawrence Chen 07322a4648 Remove completed reconciliation migration route (#9737)
* Add one-shot reconciliation migration fallback

* Remove completed reconciliation migration route
2026-08-06 18:17:56 -07:00
austinpower1258 97c8a7cadf Merge remote-tracking branch 'origin/main' into issue-9128-pdf-share-button 2026-08-06 18:09:51 -07:00
austinpower1258 f0bc80ab60 test: exercise mounted Quick Look share routing 2026-08-06 18:09:31 -07:00
cmux reload-cloud 0330d7a186 fix: include native chrome in window captures 2026-08-06 18:02:23 -07:00
Lawrence Chen 27dfa4a1ef Add one-shot reconciliation migration fallback (#9735) 2026-08-06 18:02:23 -07:00
austinpower1258 3584879fce fix: present embedded Quick Look share picker 2026-08-06 18:02:14 -07:00
austinpower1258 a2e7a77b62 test: preserve flash color after invalid sound 2026-08-06 17:55:36 -07:00
cmux reload-cloud 894b4509a8 fix: preserve screenshot command contracts 2026-08-06 17:52:18 -07:00
Lawrence Chen 9ee56b5f34 Finish coderouter billing operations and telemetry (#9732)
* Finish coderouter billing operations and telemetry

* Harden reconciliation and operator tooling

* Serialize and harden billing reconciliation
2026-08-06 17:51:07 -07:00
austinpower1258 ebdf0381ec fix: share pane attention color across renderers 2026-08-06 17:47:17 -07:00
cmux reload-cloud 471b0acb00 fix: close screenshot capture review gaps 2026-08-06 17:39:15 -07:00
austinpower1258 c83e67941a test: reproduce Quick Look share action gap 2026-08-06 17:32:54 -07:00
Austin Wang a0a4dfdd75 Merge branch 'main' into feat/configurable-pane-flash-color 2026-08-06 17:15:02 -07:00
cmux reload-cloud 45bbf1fc51 Merge remote-tracking branch 'origin/main' into issue-9065-remove-cgwindowlistcreateimage
# Conflicts:
#	Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift
#	Sources/TerminalController.swift
2026-08-06 17:12:43 -07:00
Austin Wang 9f0c47f8e4 Merge pull request #9604 from haung921209/comments-list-cli
Add cmux comments list — read-only CLI surface for diff review comments
2026-08-06 16:54:40 -07:00
Lawrence Chen d7a5b5ac0b Clarify coderouter model errors (#9728) 2026-08-06 16:21:38 -07:00
Abdulaziz AlbaharandClaude Fable 5 86f8875240 Sidebar: request an authoritative apply when a deferred row click parks (#9691)
* Add failing test: parked reveal-time sidebar click must request an apply

A click landing while row actions are detached is deferred (#9225) but the
replay only runs from the next authoritative apply, and nothing requests
one: the park mutates no SwiftUI-tracked state, the sidebar body is
Equatable-gated, and an idle app never re-arms the rows. The click stays
parked until unrelated invalidation, historically an app deactivate/
reactivate cycle.

The test drives the reveal gap (suspend + reveal without an apply), sends
the row click, and asserts the controller fires the new
onDeferredRowClickAwaitingApply seam exactly once; the seam is inert in
this commit so CI shows the test red.

Refs https://github.com/manaflow-ai/cmux/issues/9690

Co-Authored-By: Claude Fable 5 <[email protected]>

* Request an authoritative apply when a sidebar click parks awaiting actions

Fixes https://github.com/manaflow-ai/cmux/issues/9690: infrequently,
sidebar row taps did nothing until an app deactivate/reactivate cycle.

A click landing on a presentation-snapshot row (live action captures
released) is deferred and replayed from the next authoritative apply
(#9225). But the replay was passive: applies only happen when the
Equatable-gated sidebar body re-evaluates, and the park itself mutates no
SwiftUI-tracked state, so an idle app never re-armed the rows. The parked
click waited for unrelated invalidation, which an app focus cycle
eventually provided via window-key row repaints.

The controller now fires onDeferredRowClickAwaitingApply when it parks a
click; SidebarWorkspaceTableView forwards it to VerticalTabsSidebar, which
bumps a @State token read by appKitWorkspaceScrollArea (same pattern as
appKitPostResizeRefreshToken). The body re-evaluates, updateNSView
re-applies fresh action-carrying rows, and the parked click replays
immediately. The request fires only from a physical click, never from a
replay re-park, so a request per click is the ceiling and it cannot loop.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-06 16:07:34 -07:00
Austin Wang a4b76aac36 Merge pull request #9630 from manaflow-ai/fix-4133-un-center-main-thread-deadlock
Fix beachball: bound every UNUserNotificationCenter call so a wedged usernotificationsd can't deadlock the main thread
2026-08-06 16:04:21 -07:00
Lawrence Chen b9a8e20d22 Instrument coderouter usage and failures (#9725) 2026-08-06 16:00:22 -07:00
Abdulaziz Albahar 81c20891b9 iOS: unify phone browsers on the streamed Mac surface (create, blank-page state, Mac click counts, discarded-tab restore) (#9577)
* iOS: New Browser creates and streams a Mac browser pane

The New Browser picker action opened the phone-local WKWebView pane,
which no longer matches the streamed Mac-browser surface used by the
Mac Browsers rows. Add a mobile.browser.create RPC (mirroring
mobile.terminal.create) plus a browser.stream.create.v1 capability, and
route the button through the same activate-and-start stream path as
panel selection. The local pane remains as a fallback for Macs without
the capability, while disconnected, or when creation is rejected.

* iOS: purposeful blank-page state and Mac double-click taps for browser streams

A fresh New Browser pane mirrored an empty white capture, which read as a
glitch; the pane now shows an opaque new-page placeholder until the first
navigation gives the panel a URL. Double tap no longer zooms locally: taps
forward immediately with a rising Mac click count (double tap = double
click for word selection, triple = paragraph), and removing the
double-tap recognizer also removes the recognizer-failure delay from
every single click. Pinch keeps owning zoom.

* test: streaming a discarded background tab must restore its web view

A session-restored or memory-discarded background tab has only a blank
web shell; a phone stream started on it mirrors white frames until a
manual reload. Red test: mobile stream start must begin the
discard-restore navigation like revealing the tab on the Mac does.

* Restore discarded web views when a mobile browser stream starts

Streaming counts as a visibility touch: kick the discard-restore
navigation before the first capture, so a phone opening a preexisting
background tab after Mac launch streams real content instead of a
white blank that only a manual reload fixed.

* Address review: owned create request, reconcile uncertain create, generic error copy

A late mobile.browser.create result no longer activates its panel over a
selection the user made in the meantime: completion applies only while
its request ID is still current, and every competing picker action
invalidates it. An uncertain create outcome (timeout, decode failure,
client swap) now refreshes panel discovery so a committed Mac panel
surfaces in the picker instead of becoming an orphan. The create RPC's
encode-failure body uses product-level copy instead of serialization
detail.
2026-08-06 15:58:58 -07:00
Abdulaziz AlbaharandClaude Fable 5 fe18ae95fa Refresh same-account Iroh discovery on iOS startup (#9430)
* Update App Review Mac reviewer instructions

* Stabilize iOS Iroh event stream recovery

* Force live Iroh discovery for startup auto-connect

* Revert "Stabilize iOS Iroh event stream recovery"

This reverts commit 1a6cf89388.

* Make reviewer-setup.md notes block the canonical ASC template

CodeRabbit flagged conflicting instructions: the checklist said to paste
all of review-notes.md into App Store Connect while reviewer-setup.md had
its own pasteable block with the manual pairing fallback. The
reviewer-setup.md block is now the single canonical template and
review-notes.md is marked reference-only.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-06 15:37:21 -07:00
Abdulaziz Albahar e1a59bdb03 Fix grouped workspaces in All Computers (#9509)
* test(ios): preserve groups in all-computer workspace lists

* fix(ios): preserve groups in all-computer workspace lists

* test(ios): reject ambiguous group collapse migration

* fix(ios): harden multi-Mac group derivation

* test(ios): cover grouped multi-Mac workspace list
2026-08-06 15:37:03 -07:00
2280f9cc1e Fit iOS onboarding within every viewport (#9489)
* test(ios): keep onboarding content fixed in viewport

* fix(ios): fit onboarding within every viewport

* test(ios): harden onboarding viewport checks

* test(ios): cover compact onboarding layouts

* test(ios): baseline fallback footer geometry

* fix(ios): fit compact onboarding connection layout

* test(ios): require real agent onboarding capture

* fix(ios): use real agent onboarding capture

* test(ios): preserve original onboarding capture

* fix(ios): keep original onboarding captures

* test(ios): describe restored onboarding capture

* Pin GhosttyKit checksum for iOS startup fix (#9487)

* test(ios): bound onboarding chrome

* Retake onboarding captures from the current app with real workspace content

The onboarding tour framed captures were taken in July against the old
home chrome (top search bar) and placeholder fixture rows (iOS avatar
tuning / Docs / Notes with no activity). Retaken all eight variants
(workspaces + notifications x en/ja x light/dark) at 1320x2868 from the
current UI: bottom-aligned minimized search next to the floating tabs,
six workspace rows with agent-activity previews, same-day timestamps,
and unread state, so the tour shows what the shipped list really looks
like.

The workspace fixture rows now carry realistic use cases (agent fixed a
crash and opened a PR, build green, agent waiting on approval) instead
of bare terminal-name subtitles; ids workspace-main/workspace-docs and
the Docs-vs-main search disjointness the bottom-search UI tests depend
on are preserved. Five notification-feed preview bodies drop their
self-referential test-speak (wrapping-verification sentence, 'ready to
open in the iOS app') for agent-report copy, in both English and
Japanese; the one UI test asserting the approval body verbatim is
updated with it.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Top-align onboarding pages and enlarge the framed iPhone

The fit pass left each page's visual centered in the leftover space, so
the pairing card floated mid-page and the framed iPhone shrank into a
480pt cap with dead space above and below. Scene content now pins to the
top (copy, then visual directly beneath, spare space at the bottom), and
the iPhone frame cap rises to 560pt on phones / 700pt on iPad; small
viewports are unaffected because the frame layout still fits itself to
the proposed height.

Co-Authored-By: Claude Fable 5 <[email protected]>

* Use the Deep Blue bezel for dark-mode onboarding frames

The silver product frame glows against the dark tour backdrop. Dark
appearance now loads frameit's Deep Blue iPhone 17 Pro Max artwork
(same 1470x3000 screen geometry, so the existing frame-derived mask
serves both), selected alongside the appearance-specific capture; the
artwork-resolution test now covers both colorways.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Austin Wang <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-06 15:26:33 -07:00
Lawrence Chen 2c3f2fb5ca Warm up billing success page (#9718) 2026-08-06 13:36:59 -07:00
Lawrence Chen 95ef75b4fa Add client-local terminal multiview architecture (#9387)
* Implement client-local terminal multiview architecture

* Harden terminal multiview lifecycle

* Complete terminal multiview lifecycle contracts

* Fix projected terminal response and close

* Document terminal project in CLI help

* Document terminal projection resource path

* Align lifecycle docs with terminal projections

* Test enhanced prefix split routing regression

* Implement client-local terminal projections

* test(tui): cover legacy workspace selection recovery

* fix(tui): seed legacy compatibility workspace

* test(tui): reproduce slow-client output failures

* fix(tui): keep slow client projections current

* test(tui): move backpressure fixtures into workers

* test(tui): reproduce modifier prefix cancellation

* fix(tui): keep modifiers outside semantic input

* test(tui): reproduce Ctrl-D exit topology leak

* fix(tui): atomically detach exited terminal views

* test(tui): reproduce input and resize lifecycle races

* test(tui): reproduce semantic destination races

* fix(tui): make terminal projections client-local and receipt ordered

* fix(tui): preserve queued creation through terminal exit

* test(tui): reproduce zero-size split crash

* fix(tui): make degenerate split layout total

* style(tui): format degenerate split layout

* test(tui): reproduce empty startup input route

* fix(tui): establish the startup input route

* fix(tui): keep smoke terminals process-owned

* test(tui): reproduce burst input loss

* test(tui): reproduce missing size lease release loop

* fix(tui): make view size release idempotent

* test(tui): reproduce stale attachment resize failure

* fix(tui): supersede stale attachment resizes

* fix(tui): retain host input under mutation bursts

* test(tui): reproduce multiview projection leaks

* fix(tui): isolate backend view projections

* test(tui): reproduce retired attach lease leak

* fix(tui): release retired view attachments

* fix(tui): restore projection viewport state

* test(tui): reproduce boxed request schema omission

* fix(tui): publish multiview control contracts

* test(tui): isolate CLI fixtures from user config

* test(tui): reproduce sidebar host leak on shutdown

* fix(tui): bind auxiliary PTYs to daemon lifetime

* test(tui): make attach smoke lifecycle-aware

* test(tui): reproduce stale surface attach race

* fix(tui): retire stale surface attach races

* test(tui): reproduce mirror retirement attach race

* fix(tui): classify superseded surface attaches

* test(tui): keep attach smoke ephemeral

* test(tui): reproduce hidden terminal host launch failure

* fix(tui): report terminal host launch failures

* test(tui): allow raced fanout completion

* test(tui): require protocol bump for multiview

* fix(tui): version the multiview resource contract

* fix(tui): keep one prelaunch resource protocol

* test(tui): reproduce concurrent PTY descriptor leak

* fix(tui): make host disconnect teardown race-free

* test(tui): reject internal session recovery errors

* fix(tui): keep recovery errors product-facing

* test(sdk): reproduce legacy terminal snapshot rejection

* fix(sdk): decode legacy terminal snapshots

* fix(sdk): derive Rust catalog hash in test

* test(sdk): lock terminal tab identity validation

* test(tui): reproduce public projection review failures

* fix(tui): make public projections self-consistent

* fix(tui): satisfy projection lints

* fix(tui): restore cross-platform validation

* test(web): isolate hosted Subrouter environment

* fix(web): inject hosted Subrouter client factory

* fix(tui): isolate Zig SDK toolchain

* fix(tui): make recovery validation deterministic

* fix(tui): preserve terminal exit stream ownership

* test(tui): cover terminal projection recovery gaps

* test(tui): require close cleanup outside creation fence

* fix(tui): harden terminal multiview lifecycle

* test(tui): make SDK cancellation check deterministic

* test(tui): make teardown checks deterministic

* test(sdk): synchronize idle stream deadline

* test(sidebar): synchronize idle stream snapshot

* test(sdk): synchronize stream close ownership

* Classify fanout completions by deadline

* fix(tui): guard late cell pixel completion

* test(tui): synchronize clear permit assertion

* test(tui): synchronize shutdown drain assertion

* test(tui): gate ordered write drain checks

* test(tui): synchronize navigation supersession

* test(tui): bound browser event fixtures

* Make selection input fixture output-free

* Make animated selection fixture output-free

* test(tui): scale loaded browser verification

* ci(tui): serialize instrumented runtime tests

* test(remote): require instrumented latency budget

* ci(remote): budget latency under instrumentation

* test(tui): synchronize deferred host ack

* ci(tui): isolate host backpressure ordering

* ci(tui): scope valgrind origin tracking

* ci(tui): scope TLS valgrind undefined checks

* test(tui): require ordered workspace publication fence

* fix(tui): publish workspace deltas in commit order

* test(tui): reject expired fanout admission

* fix(tui): stop fanout admission at shared deadline

* style(tui): format concurrency regression

* fix(tui): fence fanout deadline admission

* test(tui): keep publication probe formatter-stable

* style(tui): stabilize deadline job formatting

* refactor(tui): name resource close plan inputs

* ci(tui): isolate valgrind runtime instrumentation

* test(tui): expose hidden host frame queue limit

* fix(tui): make host backpressure byte-authoritative

* test(tui): stop leaking SDK stream metadata

* docs(tui): describe unbounded host wakeup accurately

* ci(tui): shard valgrind leak checks

* chore: keep terminal multiview change scoped

* test(tui): make scheduler fixtures cooperative

* ci(tui): isolate application valgrind tests

* test(web): cover hosted coderouter production gate

* test(zig): assert the remaining stream deadline

* docs(tui): define all-view exit detachment

* test(tui): scale remote fixtures under instrumentation

* test(go): cover omitted terminal tab identities

* refactor(go): localize terminal alias presence check

* docs(go): clarify terminal projection validation

* ci(tui): keep normal test deadlines strict

* test(tui): isolate shutdown cancellation timing

* test(tui): reject legacy zero-view snapshots

* fix(tui): restore resource protocol v2

* test(tui): await committed terminal before teardown

* test(tui): isolate render scan instrumentation

* test(tui): make queued attach deadlines deterministic

* test(tui): accept completed browser resize
2026-08-06 08:37:11 -07:00
Lawrence Chen fb6d4c1ae5 docs(cli): sync restore help contract (#9714) 2026-08-06 07:45:14 -07:00
Lawrence Chen f5cd28364f Connect Hexclave and Stripe billing analytics to PostHog (#9702)
* Connect Stack and Stripe identities to PostHog

* Harden billing analytics delivery and sign-out

* Backfill paid plan identity on sign-in

* Clarify analytics retention disclosures

* Prevent cross-account analytics attribution

* Fail closed on unresolved analytics identity

* Gate analytics until auth identity resolves

* Preserve paid identity joins after auth gating

* Use Stack's typed cookie token store

* Observe Hexclave auth state for analytics

* Bound and coalesce analytics identity refresh

* Keep billing analytics events order independent

* Support analytics identity timeouts across browsers

* Separate auth changes from passive analytics refresh

* Fail passive identity refreshes closed

* Buffer analytics during identity revalidation

* Sanitize buffered analytics across identity changes

* Store identity marker with PostHog state

* Drop buffered events across account changes
2026-08-06 05:33:44 -07:00
Lawrence Chen a0126d11d1 Test Stripe entitlement lifecycle states (#9707) 2026-08-06 04:34:42 -07:00
Lawrence Chen 4c3325dad3 Merge pull request #9678 from manaflow-ai/codex/site-macos-nightly-cta
Add discoverable cmux Browser download page
2026-08-06 03:15:18 -07:00
cmux-lawrence 404f0108f1 fix(web): track Browser landing downloads 2026-08-06 02:59:02 -07:00
cmux-lawrence 43677144d1 test(web): cover Browser landing download analytics 2026-08-06 02:57:30 -07:00
Lawrence Chen 9bf23bcf01 Polish pricing CTAs and annual default (#9701)
* test pricing page annual default and compact CTAs

* Default pricing to annual and compact paid CTAs
2026-08-06 02:37:08 -07:00
Lawrence Chen 8527de7a4f Fix npm bootstrap latest-tag verification (#9700)
* test: cover npm-required bootstrap latest tag

* fix: honor npm bootstrap latest invariant

* fix: redact npm tag validation failures

* fix: constrain npm bootstrap latest exception

* fix: parse npm prerelease tag safely
2026-08-06 02:28:38 -07:00
Lawrence Chen 4569dbbaf0 Gate hosted coderouter on Pro (#9699) 2026-08-06 02:04:48 -07:00
Lawrence Chen ce79b78632 Support private Pi route authentication (#9695) 2026-08-05 23:55:39 -07:00
austinpower1258 15dfbfca59 fix: route every notification center call through a bounded service
UserNotifications serializes add, settings, authorization, category, and removal operations onto one internal connection queue. A removal blocked in synchronous XPC can therefore make a later main-actor add dispatch-sync behind it and beachball the app.

Route the complete macOS notification-center surface through one dedicated serial background queue. Start an independent two-second deadline at submission time so calls queued behind a permanently wedged entry still resolve as timed out; prevent expired queued work from entering the framework later, and preserve local-feedback degradation at user-visible delivery paths.

Keep the launch-time delegate assignment synchronous because Apple requires it before didFinishLaunching returns, while category installation and every XPC-touching method use the bounded service. Add deterministic service tests for wedged entry, never-completing callbacks, queue starvation, healthy completion, authorization, and removals, and run the package suite in CI.
2026-08-05 22:44:41 -07:00
austinpower1258 699c27b2d6 test: reproduce notification center caller stall 2026-08-05 22:44:41 -07:00
Lawrence Chen 79a3f646a9 Stop caching coderouter quota usage (#9694) 2026-08-05 22:39:44 -07:00
Lawrence Chen 2ac8401a37 Complete coderouter private beta routing reliability (#9692) 2026-08-05 22:22:24 -07:00
Abdulaziz AlbaharandClaude Fable 5 e01238959d iOS: Make Tailscale connection method strict (#9497)
* test(ios): require truthful Tailscale-only selection

* fix(ios): make Tailscale selection authoritative

* test(ios): verify strict transport choice copy

* fix(ios): stop Iroh discovery in strict mode

* test(ios): align strict transport fixtures

* test(ios): await failed route teardown

* test(ios): assert transport selection behavior

* fix(ios): bind onboarding preview transport choice

* test(ios): pin exclusive method selection and physical teardown

Review follow-ups from PR 9497: the onboarding UI test now asserts the
untapped method is deselected after each tap, the strict-switch test
polls the boxed live Iroh transport for physical close instead of only
the store's logical route, and the connectionMethodStore declaration
documents why nil is unreachable in the shipping app.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-05 21:06:07 -07:00
Lawrence Chen 4665c6ea8b Allow coderouter legacy cleanup after new accounts (#9689)
* Allow cleanup after new encrypted accounts are added

* Make legacy credential migration monotonic
2026-08-05 20:59:49 -07:00
Lawrence Chen caf19d82fd Move coderouter credentials to KMS-encrypted RDS (#9686) 2026-08-05 20:51:08 -07:00
Lawrence Chenandcmux-lawrence 7edde50e21 Limit transient Actions artifact retention (#9687)
Co-authored-by: cmux-lawrence <[email protected]>
2026-08-05 20:40:36 -07:00
Lawrence Chen 6bb1d7b5f7 Share coderouter usage cache across Vercel instances (#9679) 2026-08-05 17:37:53 -07:00
Lawrence Chen ad214598a7 Cache coderouter usage and collapse token auth query (#9676) 2026-08-05 17:25:08 -07:00
cmux-lawrence 592f98bdaf web: remove untranslated Browser detail 2026-08-05 17:23:59 -07:00
cmux-lawrence 49aca0ddc7 web: localize Browser availability copy 2026-08-05 17:23:02 -07:00
cmux-lawrence 01ac0386c4 web: label Browser channel explicitly 2026-08-05 17:22:05 -07:00
cmux-lawrence 288315003c web: add cmux Browser download landing 2026-08-05 17:17:14 -07:00
Lawrence Chen 7fbd9d2ff7 Parallelize coderouter usage and add dedicated landing (#9675) 2026-08-05 17:13:21 -07:00
Abdulaziz Albahar 4b7796a960 Fix end-to-end push notification reliability (#9319)
* test push notification delivery failures

* test remaining push reliability failures

* test tighten push reliability contracts

* test prevent duplicate alerts across push retries

* test persist partial push delivery retries

* test serialize persisted push retries

* test preserve unregisters across account switches

* test migrate unregister tombstones to queue

* test retry transient registration statuses

* test close push delivery retry gaps

* test bind push retries to logical payloads

* fix reliable push delivery backend

* test push registration ownership and rotation

* test fix phone admission boundary and race

* test cover push registration lifecycle races

* test reuse push expiration across retries

* test compile push lifecycle assertions

* test push readiness and responsive toggles

* test reuse expiration after stale push lease

* test isolate async URL protocol callbacks

* test align APNs retries with provider guidance

* test require APNs correlation UUIDs

* test detach scripted push transport

* fix APNs retry and request identity policy

* test isolate APNs retry classes and expiration validation

* test preserve logical push expiration across retries

* test make push registration transport deterministic

* test freeze push recipients and crash recovery state

* fix isolate durable push delivery workflow

* test enforce APNs provider boundaries

* test reuse APNs provider connections

* fix bound and reuse APNs delivery sessions

* test preserve push correlation on internal failure

* test cover push readiness recovery and live admission

* fix retain push correlation on internal failures

* fix make push registration account safe

* test reject originless push redirects

* fix reject non-http push redirects

* fix expose live push readiness

* fix fail closed on TestFlight push entitlements

* test reject false push readiness

* fix fail closed on push readiness

* test specify durable phone push queue

* test cover push queue recovery boundaries

* test expose phone forwarding controls

* fix expose phone forwarding controls

* test wire phone push queue coverage

* test preserve provider push retry delay

* test reject stale notification policy completion

* test publish push auth identity transitions

* test expose phone push queue health

* test expose truthful push test stage

* fix make phone push delivery durable

* test report degraded push retry storage

* test retry transient push authentication

* fix retry transient push authentication

* fix expose end-to-end push readiness

* fix serialize push settings mutations

* fix emulate signing entitlement booleans

* fix compile phone push RPC handlers

* fix route all push popover entrypoints

* test stabilize push database stress cases

* fix ios push readiness module dependency

* fix audit missing push credentials

* test bound device token stress cases

* test expose unconfigured push provider

* fix report unconfigured push provider

* fix import push status in ios coordinator

* test: cover push review regressions

* fix: harden phone push delivery end to end

* fix return restored agent argument

* fix: address push reliability review findings

Server: delivery no longer aborts with the client request (partial APNs
outcomes survive disconnects), provider backoffs are clamped to the event
TTL or finalized as expired, expired records stop blocking reclaims,
account deletion waits for active device delivery leases, and lease
release uses a partial index instead of a table scan.

Mac: queue restore adopts the observed identity so the identity stream's
first yield no longer clears the persisted queue, the queue store
scavenges abandoned tmp snapshots, and in-flight policy discard uses the
delivery-identity index.

iOS: push-readiness preview env key routed through UITestConfig, device
limit count localized via localizedStringWithFormat.

Tests: TTL clamp unit + route coverage, aborted-signal regression,
unknown-admission fail-closed, negative Retry-After clamp, retainOnly
generation scope, account-deletion lease block.

* fix notifications phone push settings bindings

* test clamp provider push retry to event ttl

* test allow retry-after rounding drift

* fix: route phone_push.status.get auth failures to reauth disconnect

A revoked, expired, or account-mismatched token during the authenticated
status probe previously left the shell connected and only cleared push
readiness. Definitive authorization errors now route through
disconnectForAuthorizationFailureIfNeeded like every other authenticated
RPC; transient failures still just clear the cached Mac status.

* fix: lockfile guard false positive, IPA gate messages, test isolation

The Package.resolved policy flagged this PR for adding a path dependency
(CmuxMobileRPC into CmuxMobileShellUI) that was already reachable through
CmuxMobileShell: Xcode's originHash covers resolved remote inputs, so no
honest resolution changes any lockfile byte (verified against Xcode 26.6,
which rewrites the swift-CLI hash back). The guard now requires lockfile
diffs only for dependency edits that can move the pinned set: changed
remote requirements, or path edits pulling previously unreachable remote
pins in or out. Verified both negative cases still fail.

upload-testflight entitlement-gate failures now name the durable IPA
instead of the deleted temp-extracted .app path.

Two push-coordinator lifecycle tests now use isolated UserDefaults suites
instead of leaking through .standard.

* test: cover deferred push edge cases

* fix: close deferred push reliability gaps

* test: make injected push clock assertion causal

* fix iOS push status text returns

* fix: make push timeout sendable

* fix: baseline inherited iOS convention debt

* fix: route iOS convention baseline lint

* fix: make live push clock sendable

* test: cover remaining push delivery races

* test: harden push verification boundaries

* fix: close final push delivery races

* test: cover final push edge cases

* fix: preserve truthful push readiness results

* test: synchronize push queue lock coverage

* fix: call the Darwin file lock function

* fix: preserve push preview initializer order

* test: fix workspace drop fixture initializer

* test: synchronize push settings mutations

* test: tap push switch controls reliably

* test: require push opt-in at workspace list

* test: correct push lifecycle assertions

* test: make push settings regression observable

* test: keep push settings assertions alive headlessly

* fix: enable reliable mobile push notifications
2026-08-05 15:41:33 -07:00
Abdulaziz AlbaharandClaude Fable 5 6d0d313622 Read hosted tenant delete token lazily from process.env (#9669)
0eecd5afea (#9607) switched SUBROUTER_STACK_TENANT_DELETE_TOKEN to the
validated env object, but t3-env freezes values at first import, so
tenant-control configuration became unobservable after boot and the
unconfigured paths broke: the exchange route returns 200 instead of 503
and account deletion fires hosted tenant deletes for accounts that never
enabled Subrouter. web tests have been red on main since (CI paused).
env.ts still validates presence on Vercel non-preview deployments.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-05 15:40:58 -07:00
Lawrence Chen 2d9ba4b090 Proxy Codex model discovery through CodeRouter (#9641)
* test: require CodeRouter model discovery proxy

* Proxy Codex model discovery through CodeRouter
2026-08-05 02:22:46 -07:00
Lawrence Chen fe198fb88d Restore hosted Subrouter CLI configuration (#9638)
* test: preserve hosted Subrouter CLI config

* fix: restore hosted Subrouter CLI config
2026-08-05 02:22:02 -07:00
Lawrence Chen 81ae632de6 Fix SDK registry bootstrap verification (#9640)
* test: cover omitted PyPI publisher claims

* fix: repair SDK bootstrap verification
2026-08-05 02:14:09 -07:00
Lawrence Chen 40ff1c1667 Restore Vercel OIDC credentials for RDS access (#9637)
* test: require Vercel OIDC for RDS pools

* Restore Vercel OIDC credentials for runtime RDS access
2026-08-05 01:28:33 -07:00
Lawrence Chen 0447b3cb45 Fix CodeRouter responses middleware routing (#9636)
* test: preserve CodeRouter data-plane route through middleware

* Bypass localization for CodeRouter responses
2026-08-05 01:08:19 -07:00
Lawrence Chen 6e9ca01fc4 Move CodeRouter data plane to Vercel (#9633)
* test: require Vercel-native CodeRouter data plane

* Move CodeRouter data plane to Vercel

* Allow authenticated operators to run RDS migrations

* Serialize refreshes and fail over cooked accounts
2026-08-05 00:56:28 -07:00
Austin Wang c9e66e8dc9 Merge pull request #9631 from manaflow-ai/fix-9624-restore-caller-identification
Fix restore caller surface resolution
2026-08-05 00:55:35 -07:00
austinpower1258 7ee8f64f9d fix(cli): restore caller surface resolution 2026-08-04 23:50:09 -07:00
Lawrence Chen d228709009 Merge pull request #9632 from manaflow-ai/codex/linux-auto-update-cta
web: prioritize auto-updating Linux installer
2026-08-04 23:21:15 -07:00
cmux-lawrence 0c80c2896c web: prioritize auto-updating Linux installer 2026-08-04 23:16:06 -07:00
cmux-lawrence ff147074bd test(web): require auto-updating Linux installer CTA 2026-08-04 23:06:30 -07:00
Austin Wang ec0cd3308e Merge pull request #9324 from mykmelez/myk/fix-stale-port-badges
Retire stale sidebar port badges despite vanished TTYs, privileged owners, and zombies
2026-08-04 22:30:30 -07:00
Austin Wang ca1e087edf Merge pull request #9621 from manaflow-ai/fix-9619-restore-preserves-workspace-title
Preserve workspace titles during session restore
2026-08-04 22:08:03 -07:00
Lawrence Chen 0ff3fdc2f2 Merge pull request #9628 from manaflow-ai/codex/website-download-routes
Publish verified cmux Browser nightly downloads
2026-08-04 22:06:02 -07:00
cmux-lawrence 3060a4054e web: align mac downloads with universal artifacts 2026-08-04 22:00:48 -07:00
austinpower1258 929dee81ef fix: reconcile title after focused surface transfer 2026-08-04 21:59:03 -07:00
austinpower1258 f9ecd31fdc Fix diff comment payload integration 2026-08-04 21:58:28 -07:00
austinpower1258 fca797fdd8 Add coverage for shared diff comment lifecycle JSON 2026-08-04 21:56:03 -07:00
cmux-lawrence 17709529d0 web: add verified browser nightly downloads 2026-08-04 21:50:48 -07:00
austinpower1258 10eb95cbf6 test(cli): reproduce restore caller resolution regression 2026-08-04 21:38:30 -07:00
haung921209 0d93f18f85 Resolve comments CLI strings from the enclosing app bundle
The internationalization check is right that cmux-cli ships no string catalog,
so String(localized:) in the CLI renders its default value — measured earlier
when a plural variation failed to select. Rather than add a resource phase to a
command-line tool, this routes the command's strings through
CMUXDiffViewerLocalization, which already resolves
CLIExecutableLocator.enclosingAppBundle() and honors AppleLanguages.

Verified per locale: help renders 使い方 under ja and 사용법 under ko, the list
header renders "Комментарии ревью: 3" under ru, state labels render 未対応 and
в ожидании, and the guards render their Russian and Japanese messages.

Also documents DiffComment.init and the payload tests.
2026-08-05 12:24:53 +09:00
haung921209 0391f22bda Keep count selection in code, drop numeral-governed nouns from plural strings
The Slavic plural finding was right, but CLDR variations do not fire here: the
CLI resolves strings against its own bundle, which carries no string catalog, so
every localized CLI string renders its defaultValue. Measured — with the header
moved to variations, count=1 printed "1 review comments".

So selection stays in code (correct singular today) and the plural strings avoid
letting the numeral govern the noun, which keeps one form grammatical for every
count above one: ru "Комментарии ревью: %1$lld", uk "Коментарі рецензування:
%1$lld", pl "Komentarze przeglądu: %1$lld", ar "تعليقات المراجعة: %1$lld".
Bosnian already used a form valid for every count above one.
2026-08-05 12:08:52 +09:00
austinpower1258 b5a6cb640f fix: harden restored title boundary lifecycle 2026-08-04 20:04:07 -07:00
haung921209 10a04aeede Make DiffCommentPayload a constructable mapper instead of a static namespace
The ambient-global-state rule flags a caseless enum whose whole API is static
helpers, and it was the right call here: an instance can own the formatter, so
"one formatter per reply" is now a property of the type rather than something
each caller has to remember. Tests can inject a formatter too.

TerminalController, DiffCommentsBridge, and the tests construct a mapper; the
bridge's list reply collapses to comments.map(payload.json).
2026-08-05 11:44:38 +09:00
haung921209 9b493b8aba Point the CmuxDiffComments product at its own package reference
The product dependency I added carried CMUXDebugLog's package UUID with a
CmuxDiffComments comment, so a clean resolve could have associated the product
with the wrong package. My local builds hid it behind an already-resolved
SourcePackages cache.

Audited every XCSwiftPackageProductDependency in the project against its
referenced object: no other mismatch. Verified with SourcePackages deleted so
resolution ran from scratch.

Also documents the `ls` alias in the comments contract row.
2026-08-05 11:34:19 +09:00
austinpower1258 b3c16f3478 fix: preserve restored workspace titles 2026-08-04 19:33:33 -07:00
austinpower1258 24f6e13fc1 test: preserve titles across restore bootstrap 2026-08-04 19:17:25 -07:00
haung921209 e4f26e572a Extract DiffComment and the payload mapping into CmuxDiffComments
Per the package-boundary review: the comment model and its wire mapping are
reusable domain logic shared by the WebKit bridge, the socket method, and the
tests, so they move behind a SwiftPM boundary.

- Packages/macOS/CmuxDiffComments exposes public DiffComment and
  DiffCommentPayload and depends on Foundation only.
- Persistence stays app-side: DiffCommentStore keeps its directory resolution
  and the SessionRestorePolicy test check, so no app-wide dependency crosses
  into the package.
- The app target, bridge, socket handler, and both test files import the
  package; no behavior changes.
2026-08-05 11:12:16 +09:00
Austin Wang 2f3d92281f Merge pull request #9566 from manaflow-ai/issue-9518-dock-focus-cmd-l-cmd-shift-t
Route all surface shortcuts through focused Dock
2026-08-04 19:02:07 -07:00
haung921209 fc5ef0d714 Move comment wire mapping into DiffCommentPayload
Both surfaces that serialize review comments now share one type that touches
neither AppKit nor controller state: TerminalController keeps only the socket
dispatch, DiffCommentsBridge keeps only the WebKit glue, and the mapping and
consumed-filtering live in DiffCommentPayload with the tests pointed at it.

This also makes the two response paths structurally identical, so the webview
reply cannot drift back to allocating a formatter per comment. If the project
wants this behind a SwiftPM package, it is now a file move rather than a
refactor.
2026-08-05 10:37:43 +09:00
haung921209 05419f4eba Translate the new catalog keys, generalize the socket error, add docstrings
- Replace the English fallbacks with real translations for all 18 remaining
  locales across the 13 keys this PR adds. The internationalization rule lists
  copied English as an unacceptable way to fill a locale slot, so matching the
  catalog's existing fallback habit was not enough for new keys. CLI tokens and
  format specifiers are preserved verbatim.
- socket.comments.missingRepoRoot now reads "A repository path is required."
  The pre-merge privacy check wants API identifiers out of user-facing text;
  the CLI already fails with its own message first, so a direct socket caller
  loses nothing it cannot get from the invalid_params code.
- Document the four comments CLI helpers.
2026-08-05 10:15:58 +09:00
Lawrence Chen a2b3c10f11 Fix cmux TUI release packaging (#9608)
* Fix cmux TUI release packaging

* Allow TUI release branch dry runs

* Update TUI package smoke command

* Revert "Allow TUI release branch dry runs"

This reverts commit 0ccb643704.
2026-08-04 18:15:32 -07:00
Lawrence Chen 0eecd5afea Fix CodeRouter trusted tenant exchange (#9607)
* Make hosted tenant exchange self-contained

* Route CLI auth through versioned exchange endpoint

* Use a semantic tenant exchange endpoint

* Use CodeRouter control header for hosted exchange

* Read hosted credentials through validated runtime env
2026-08-04 18:12:23 -07:00
haung921209 3a723ea7f1 Reject option tokens as --repo values and split the list header by count
- parseOption takes the token after --repo verbatim, so `--repo --all` would
  resolve a repository named "--all" and hand it to git as a path. Reject a
  value starting with --, pointing at ./-name for dash-prefixed paths.
- Replace the literal "comment(s)" header with cli.comments.list.header.one
  and .other selected by count, matching how cli.memory.output.processCount is
  written. Both keys cover all 20 locales.
2026-08-05 10:06:09 +09:00
haung921209 4b7e990b3e Localize the comments CLI strings, reject stray arguments, reuse the bridge formatter
- comments list now rejects every unrecognized remainder token, not only ones
  starting with --, so a stray positional cannot be silently ignored.
- The webview bridge's comments.list response builds one ISO8601DateFormatter
  and passes it to commentJSON, matching the socket response.
- Route the command's user-facing text through the catalog: nine new
  cli.comments.* keys for the errors and list output, each covering all 20
  locales with the reviewed ja value and the accepted English fallback.
2026-08-05 09:40:37 +09:00
haung921209 86311bc8ab Cover every catalog locale for the two new keys
cli.comments.usage and socket.comments.missingRepoRoot now carry entries for
all 20 locales the catalog supports. The reviewed en and ja values are
unchanged; the remaining 18 locales use the accepted English fallback, which
is how 229 of the catalog's other fully-covered keys are written.
2026-08-05 09:25:27 +09:00
haung921209 14fdf2a458 Address review: locale entries, one formatter per reply, reject unknown flags
- Localizable.xcstrings: add the ja entry for cli.comments.usage and add the
  socket.comments.missingRepoRoot key, which the handler referenced but the
  catalog never defined. Both keys now match the en+ja coverage that the
  other cli.*.usage and socket.* keys use.
- DiffCommentsBridge: add commentJSON(_:formatter:) so a caller mapping many
  comments allocates one ISO8601DateFormatter per reply instead of one per
  comment; the existing single-comment signature delegates to it.
- cmux comments list: reject unrecognized -- options instead of ignoring
  them, so a typo cannot read as a supported request.
2026-08-05 09:17:55 +09:00
haung921209 f8299cd77e Add cmux comments list backed by a comments.list socket method
Review comments saved in the diff viewer already reach agents through the
TextBox pending pool (push). This adds the pull direction: a read-only CLI
that asks the running app for a repository's saved comments, so external
tools never depend on the store's key derivation or in-memory cache.

- comments.list v2 method: canonicalizes repo_root via DiffCommentStore and
  delegates to a pure commentsListPayload(comments:repoRoot:includeConsumed:)
  so the reply shape is testable without a socket
- cmux comments list [--repo <path>] [--all] [--json]: resolves the git
  toplevel (default: cwd), prints a human summary or JSON
- CommentsListPayloadTests: default listing omits consumed comments,
  include_consumed adds them with an ISO8601 consumedAt, anchor fields are
  preserved, an empty store reports zero
- cli-contract.md: command table row + no-socket help probe
2026-08-05 08:51:21 +09:00
Lawrence Chen 4cf7cc39a6 Add copy-paste CLI authorization page (#9597)
* Add copy-paste CLI authorization page

* Exclude device authorization from locale routing
2026-08-04 15:50:50 -07:00
austinpower1258 314224f4ed fix: route surface shortcuts to focused Dock 2026-08-04 15:17:48 -07:00
austinpower1258 c40b650276 test: cover Dock shortcut routing gaps 2026-08-04 15:17:33 -07:00
Austin Wang e48438c4ef Merge pull request #9536 from manaflow-ai/issue-9462-google-sheets-browser-pane-cpu-hang
Use current Safari identity for Google Sheets
2026-08-04 12:03:37 -07:00
Austin Wang 91b195c1d8 Merge pull request #8612 from manaflow-ai/fix/atd-sidebar-link-click-8596
Fix AppKit sidebar description link clicks
2026-08-04 12:02:28 -07:00
Austin Wang 2c26320ab8 Merge pull request #9580 from manaflow-ai/fix/pr-8614-merge-carrier
Land PR #8614 after resolving current main conflicts
2026-08-04 08:00:58 -07:00
austinpower1258 c685309bba Merge active orphan fixture repair into PR #8614 carrier 2026-08-04 07:48:20 -07:00
austinpower1258 f155b74809 cmuxTests: model active orphan shortcut route 2026-08-04 07:48:04 -07:00
austinpower1258 d927a96d63 Merge orphan route pruning repair into PR #8614 carrier 2026-08-04 07:35:26 -07:00
austinpower1258 b99b5c2b28 cmuxTests: await orphan shortcut route pruning 2026-08-04 07:20:04 -07:00
austinpower1258 00784263f0 Merge rect fixture compile repair into PR #8614 carrier 2026-08-04 06:42:23 -07:00
austinpower1258 470552b843 cmuxTests: keep FIFO diagnostic in one string literal 2026-08-04 06:42:16 -07:00
austinpower1258 b3b6c65c2f Merge rect publication fixture repair into PR #8614 carrier 2026-08-04 06:31:22 -07:00
austinpower1258 f5eae784e8 cmuxTests: route rect replies past setup commands 2026-08-04 06:31:12 -07:00
austinpower1258 3dfb475d52 Merge latest main into PR #8614 carrier 2026-08-04 06:02:31 -07:00
austinpower1258 2b5dc40667 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 06:01:42 -07:00
Abdulaziz Albahar 28f88c819c Stagger Iroh relay credential refreshes (#9581)
* test: require staggered relay refresh slots

* fix: stagger relay credential refresh by endpoint role

* test: cover client relay refresh slot distribution

* test: assert stable bounded relay refresh slots
2026-08-04 07:57:13 -05:00
austinpower1258 11cab6a727 Merge Sendable clock default into PR #8614 carrier 2026-08-04 05:45:56 -07:00
austinpower1258 1d309236c8 Use a Sendable focus-history clock default 2026-08-04 05:45:46 -07:00
austinpower1258 05e79d3252 Merge current main and final review fixes into PR #8614 carrier 2026-08-04 05:30:27 -07:00
austinpower1258 2e1427c499 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 05:30:09 -07:00
Austin Wangandejc3 539a1b2ad5 cmuxTests: repair three red suites, one of which was killing its own test host (#9572)
* cmuxTests: derive the theme reload target from a dash-free socket suffix

The CLI derives a theme reload target from the socket file name, collapsing every run of
non-alphanumerics in the slug to a dot. #6452 made this fixture's socket path unique with a raw
UUID to stop two runs colliding in /tmp, which put the UUID's dashes into the derived identifier
as dots, so the expected literal could no longer match and the test waited out its five seconds.
The stdout assertion kept passing because the derived id still has the expected value as a
prefix, which is why this read as a timeout rather than a string mismatch.

Keeps the unique suffix hex-only so the expected identifier stays a plain template instead of a
call into the CLI's own helper, which would agree by construction.

* cmuxTests: drop two palette assertions for a gate that no longer exists

#8173 replaced the fork-probe reuse gate: `!cachedResultHadFallback` became
`cachedResultIsFresh`, and the fallback case is now re-verified against SharedLiveAgentIndex at
the call site instead of being refused outright. The parameter stayed in both signatures, so
these two assertions still compiled while asserting the opposite of what the product does, and
WorkspaceForkConversationContextMenuTests asserts the new contract in both directions a few
files away.

Removes the two assertions whose only purpose was the removed term, and renames the clear-side
test to say what it still covers.

* cmuxTests: stop the remote-connection suite killing its own test host

Three separate problems, in order of blast radius.

Two assertions indexed `operations` right after asserting its count. A count assertion does not
stop execution, so on failure the next line trapped with Index out of range and took the shared
test host down, and every remaining test in the shard never ran. Measured twice in one run.

Four @MainActor tests waited on a DispatchSemaphore. configureRemoteConnection enqueues its
session transition as a main-actor Task, so blocking the main actor stopped the very work being
waited on from ever being scheduled. They now use expectations, which pump the run loop.

Fifteen fixtures passed an unresolved %C control template. The broker deliberately refuses to
own a path it cannot resolve, so no lease was ever taken and cleanup could not run; six inverted
expectations were passing vacuously as a result. They now use the resolved form ssh -G produces,
and a new test pins the unowned-template policy so the fixtures cannot quietly regress to it.

Two more read activeRemoteSessionControllerID straight after configureRemoteConnection and now
await the transition instead.

* cmuxTests: point the daemon-upload tests at the transport that replaced scp

Two tests waited on an scp invocation that no longer happens. #8434 moved the daemon upload off scp
and onto the ssh exec channel, streaming the binary into `cat >`, and did not touch these tests.
Their stubs only fulfilled inside an `executable == "/usr/bin/scp"` branch, so the expectation
could never fire, the wait spent its whole budget, and the unwrap on the next line reported nil.

Both now capture the upload from the ssh branch. The property each one is about is unchanged: the
daemon still has to land on an absolute path under the remote HOME, that path just travels inside
the remote command instead of an scp destination, so the assertion moved with it.

The scp branch is kept and fails loudly. If the upload ever returns to scp, that should be a
sentence in the failure output rather than a silent timeout, which is precisely how these two broke.

The reinstall test also now records how many capability hellos preceded the upload and requires at
least one. Retargeting alone would have let it pass on a first install, which is not the
missing-pty-capability path it is named for.

Renamed the first test off "ScpDestination" since it no longer describes what is asserted.

* cmuxTests: fix three CLI tests that could not pass, and stop one hiding why

Three separate causes, all in the fixtures rather than the product.

Two socket-selection tests replied to the CLI with a bareword. SocketClient only treats OK, OK …,
PONG, ERROR: … or JSON as a complete single-line reply, so a bareword sends it into the multiline
drain pass, where reconfiguring the receive timeout on a socket whose peer already hung up fails with
EINVAL — and the CLI reports "Invalid argument" instead of the reply it already had buffered. The
replies are now OK-framed. These were the only two barewords in the suite, which is why eleven
near-identical siblings pass.

Both now also assert which responder received the request. That is the property they exist for —
the tagged socket is chosen and the stable one is not — and unlike the stdout comparison it cannot
be made vacuous by a future change to the reply.

A fork-diagnostics fixture passed agent "project-agent", which is not in the CLI's catalog, so the
command exited before emitting any JSON. The test has never passed; it went in already red alongside
the pi-family gate it is meant to cover. It now uses grok, a catalog agent that is neither pi-family
nor one of the transcript-walking agents, so the basename gate is still what is under test.

The shared helper turned all of that into a JSON decoding failure, because it only expected a zero
exit before parsing. It now requires the exit status and a completed run, so the next fixture mistake
reports the CLI's own error text instead of a parse error.

* cmuxTests: pair the pi-basename fixture with an agent that can actually fork

The pi-family basename test asked for fork_command_available, fork_supported and
fork_startup_input_available, but its fixture stored the record under a grok
launcher pair. A captured launch command is only used when its launcher describes
the requested agent, so the grok/omo pair was dropped as untrusted, no fork argv
was built for any agent, and all four assertions failed on
agent_has_no_fork_command without ever reaching the rule under test.

Store the record under opencode instead, whose wrapper launcher is omo. The
capture is now trusted, the fork argv resolves through the omo launcher, and the
executable basename stays /tmp/pi so the disagreement between the structured
identity and the basename is still what the test measures. The omo launcher also
answers fork support before the opencode executable probe, so the result does not
depend on a /tmp/pi existing on the machine running the test.

* cmuxTests: assert the stderr-closed CLI does not crash, instead of a CLI that no longer exists

This test asserted exit 1 and a "Usage:" banner on stdout. Neither has been true since #f48922aa94:
an unknown command exits 2 with a single line and no usage dump, and that line goes to stderr — which
the test closes with 2>&-. So it could not pass, and the crash it was written for was not what it
checked.

The regression is still worth guarding. cc4a6109d8 replaced FileHandle.standardError.write, which
raises and aborts when stderr is closed, with a raw Darwin.write that returns -1 on EBADF. The oracle
is therefore that the CLI exited on its own terms rather than dying from a signal, so ProcessRunResult
now carries terminationReason and both runners set it. Without that, a signalled process is
indistinguishable from an ordinary non-zero exit, because its terminationStatus is just the signal
number.

The command now runs under exec, so the process being waited on is the CLI rather than the shell. A
shell reports a signalled child as a normal exit with status 128+signal, which would have hidden
exactly the crash being tested.

It also pins CMUX_SOCKET_PATH and the home directory. Socket resolution otherwise consults a
machine-global marker file, and a spawn with a pristine temp home was measured reaching a real running
app — which would make the exit code depend on what is running on the machine. With the socket pinned
the unknown-command path is a single branch, so the test asserts exit 2 exactly rather than settling
for non-zero.

* cmuxTests: isolate the CLI regression suite from the machine's own cmux

A CLI spawned from this suite with a pristine temp home and a scrubbed
environment still reached a real running app. CFFIXED_USER_HOME moves the socket
directory but not socket discovery: the CLI also reads the machine-wide
/tmp/cmux-last-socket-path marker, and for an untagged debug build it scans /tmp
for cmux-debug-*.sock and connects to what it finds. Resolution runs before the
command dispatches, so even `claude-teams --help` did this. Every spawn site that
is not itself testing resolution now pins CMUX_SOCKET_PATH to a per-run path, the
three stable-variant tests write the marker inside their own temp home, and
runShell takes an explicit environment instead of handing the child everything
the test host was launched with.

Two tests bound a responder on /tmp/cmux.sock, the release app's socket path, and
UnixSocketResponder unlinks before it binds, so a run could take the control
socket away from a release app in use. The early returns meant to prevent that
raced the app, disagreed about whether a dangling symlink counts as present, and
turned the tests into silent passes. The symlink fallback case moves to the
user-scoped stable path inside its temp home. The legacy case keeps the part that
needs the real path, that /tmp/cmux.sock is classified as a stable implicit
default, and no longer creates, binds, or removes it. Three more guards tested
paths inside a freshly created temp home and could never fire, so they are gone.

stderr was pointed at the stdout pipe while about thirty tests parse stdout as
JSON or compare it to an exact reply, so one diagnostic line from the runtime
broke a content check instead of naming itself. stderr now has its own pipe,
failure messages carry both streams, and the negative checks that meant "the CLI
never said this anywhere" read both rather than silently narrowing to stdout.
Readers for both pipes start before the wait, because reading after
waitUntilExit deadlocks once a child fills a pipe buffer and that looks like a
hang inside the CLI. A launch failure is reported on stdout as well as stderr,
since five sibling suites share this runner and print only stdout.

Runs that assert nothing about latency no longer carry a 5s cap and take a 60s
guard instead, which still fails a stuck CLI rather than passing slowly. The two
browser-download tests keep their 3s and 16s caps, where the deadline is the
assertion. The two theme tests with fixed bundle identifiers now scope them per
run, since the reload notification goes out machine-wide; for the nightly one
that means scoping the socket file name too, because the identifier is derived
from it.

* cmuxTests: assert the exit code this fixture actually produces

The stderr-closed test asserted exit 2, the unknown-command code. Measured, it exits 1: the pinned
socket has no listener, so the CLI fails at connect and the top-level handler returns before the
unknown-command arm runs. That ordering makes the fixture a better exercise of what the test guards,
not a worse one, because the connect error is written to the stderr the test has closed. The run
confirmed the guard itself holds — termination reason was a normal exit, not a signal.

* cmuxTests: report stderr in sessions helper failures

* cmuxTests: preserve restore assertions after stream split

* cmuxTests: close review gaps in process and upload fixtures

* cmuxTests: align remote fixtures with streamed input and scoped identity

* cmuxTests: yield main actor while awaiting daemon upload

* cmuxTests: repair CLI regression fixtures and child lifetimes

* cmuxTests: isolate daemon bootstrap fixtures from ControlMaster

* cmuxTests: keep theme notification state nonisolated

* cmuxTests: detach live argv fixture from test host

* cmuxTests: own Go discovery in daemon reinstall fixture

* cmuxTests: make subprocess and bootstrap fixtures deterministic

* cmuxTests: remove detached fixture wall clock

* cmuxTests: use async-safe scoped locking

* cmuxTests: make off-host process work concurrent

* cmuxTests: keep blocking process wait off cooperative executor

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 05:29:17 -07:00
austinpower1258 bb881787bd Merge focus-dismissal review fix into PR #8614 carrier 2026-08-04 05:22:01 -07:00
austinpower1258 dc64d16d3a tests: stop after focus-dismissal timeout 2026-08-04 05:21:48 -07:00
austinpower1258 ef49d76840 Merge warning-free focus-history clock into PR #8614 carrier 2026-08-04 05:18:05 -07:00
austinpower1258 e495092446 Mark focus-history clock closure Sendable 2026-08-04 05:17:49 -07:00
austinpower1258 aa5e7dc9f6 Merge final shortcut test repairs into PR #8614 carrier 2026-08-04 05:09:21 -07:00
austinpower1258 ef4c5de385 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 04:54:23 -07:00
austinpower1258 96e515bad7 tests: align shortcut suite with current reload policy 2026-08-04 04:54:19 -07:00
austinpower1258 c90a038699 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596 2026-08-04 04:20:28 -07:00
austinpower1258 af88c26294 Merge review fixes into PR #8614 carrier 2026-08-04 04:20:18 -07:00
austinpower1258 5e06c67e87 tests: prove managed shortcut writes are refused 2026-08-04 04:19:48 -07:00
Austin Wang a0680fd439 Merge pull request #9569 from manaflow-ai/cli-headless-fixes
cmuxTests: integrate headless CLI mock fixes against current main
2026-08-04 04:11:26 -07:00
EJandejc3 2b6ea53707 cmuxTests: gate the keyDown-forward assertion on a live surface (#8504)
* cmuxTests: gate the keyDown-forward assertion on a live surface

testTypingRepairForwardsKeyDown asserted that the repaired keyDown reached
libghostty, but forwarding only happens once the runtime surface is live, and the
headless xctest host does not always spin one up. The assertion then fails for a
reason that has nothing to do with typing repair.

Gate the forward observation on a live surface, matching the constraint the
neighbouring tests in this file already respect.

This commit previously also carried a fix for remote-workspace restore handing
the terminal a local working directory. That fix is #8634, which restructures the
same logic behind a named predicate and gates both paths rather than one, so it
is dropped here to avoid two competing changes to the same function.

* cmuxTests: skip the keyDown-forward check visibly instead of silently dropping it

The forward assertion was wrapped in `if terminalPanel.surface.hasLiveSurface`, which means on a host
without a live libghostty surface the oracle simply disappears and the test still reports green. The
comment even said the headless host does not always provide one, so the coverage was not just
conditional, it was silently conditional, and nothing distinguished a run that checked the forwarding
from a run that did not.

XCTSkipUnless says it out loud. The repair-routing assertions above run first and are checked on every
host either way, so nothing that was verified before is verified less now.

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 03:57:33 -07:00
austinpower1258 1943a80b7a cmuxTests: align headless CLI lifecycle expectations 2026-08-04 03:50:33 -07:00
Austin Wangandejc3 99f7e1baf6 Recover one pane instead of restarting the session when its seed retention overflows (#9575)
* remote-tmux: cover a pane that retains past its own ceiling

Red on purpose. Two tests drive the two branches that can reach a pane's retention
ceiling and assert what should happen: the pane recovers and the transport keeps
running. Today both fail with the connection in `.reconnecting` and no pane marked
for a deferred reseed.

They need one change to be reachable at all. The per-pane comparison read the
hard-coded static while the seed tests inject a small mirror limit, so no fixture
could reach this branch and it has never had coverage. The comparison now goes
through `min(static, mirror budget)`, which is the same value at the shipped
default because the mirror-wide default is exactly twice the per-pane static.

That bound is also the honest one: without it a single pane may retain more than
the whole mirror is allowed, which is why one retaining pane always crosses the
per-pane line first and the mirror-wide check only becomes reachable with three
panes retaining at once.

* remote-tmux: recover one pane instead of restarting the session on seed overflow

A pane whose surface has not reached its remote size yet cannot accept a seed, so the
mirror retains it. When that retention crossed the pane's ceiling the mirror called
`beginReconnecting()`, which is the "this control stream is unusable" path. The stream
was fine; a renderer had run out of room.

What it cost: the state change wipes every pane's retained seed and every deferred
reseed, then the reattach reseeds all of them with `clearScrollback: true`. That emits
ESC[3J, so each pane loses its locally saved lines and gets back at most what
`capture-pane` returns. One slow pane truncated the scrollback of every other pane in
the session.

The remedy was already in this file, three lines below each of the three call sites,
and already used for the neighbouring condition: drop that pane's retained bytes, mark
it, and re-seed it from an authoritative `capture-pane` once its grid is ready. The
recapture is what makes dropping the bytes safe, and it touches one pane.

The condition is reachable in ordinary use — a large `cat` in a pane whose tab has not
been opened yet will do it on a fast link. It is close to unreachable on the
high-latency path that motivated #8436, so the harsh branch fired mainly in conditions
that PR was not about.

Of the eighteen `beginReconnecting()` calls in Sources, this was the only one outside
the connection itself and the only one that could fire while the stream was healthy.

* remote-tmux: a seed budget ceiling no longer restarts a healthy stream

Two sites in the connection's own seed accounting called `beginReconnecting()` under an
explicit `connectionState == .connected` guard, so a producer running out of room restarted a
stream that was working. The reattach then reseeds every pane with `clearScrollback`, emitting
ESC[3J, so one slow pane truncated every sibling pane's scrollback — the same blast radius the
mirror-side change removes, one layer down. Two independent design reviews ranked this the
worst remaining problem, and it makes the claim in this branch's description true rather than
nearly true.

`recoverPaneSeedBudget` discards that pane's retained bytes and re-seeds it authoritatively.
Freeing the bytes first is what makes room for the re-seed to be admitted, and the re-seed is
deferred to the next main-actor turn because this runs inside the reservation that just failed
— a synchronous call re-enters it and recurses until the stack overflows, which a fuzz run
measured.

The budget test follows: it pinned `.reconnecting` and an empty seed table, and now pins the
blast radius instead. The pane that crossed the budget is released, a pane that did not keeps
its seed, and only the offender's bytes return to the budget.

* remote-tmux: recover an overflowing pane seed once under total-budget backpressure

reservePendingPaneSeedBytes already recovers the pane (recording
pane-seed-total-backpressure) when the aggregate budget is exhausted, and
the caller's combined guard then recovered it a second time under
pane-seed-backpressure. Each recovery enqueues a clear-scrollback reseed,
so one overflow scheduled two. Split the guard so the per-pane ceiling
keeps its marker and a reserve failure returns without recovering again.

* remote-tmux: cover bounded pane seed recovery retries

* remote-tmux: bound deferred pane seed recovery

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 03:49:46 -07:00
9bdeca5d91 cmuxTests: settle async focus broadcasts before asserting their side effects (#8507)
* tests: settle focus broadcasts before asserting focus-history and notification dismissal

Five tests in the Workspace/TabManager suites called Workspace.focusPanel (or
addWorkspace) and immediately asserted the side effects that focus is supposed to
produce: a focus-history entry, and dismissal of the focused pane's unread
notification. Those side effects no longer happen synchronously.

Both of them run from the .ghosttyDidFocusSurface observer in TabManager, and that
notification is emitted through FocusSurfaceBroadcaster, which by contract never
delivers synchronously -- it coalesces onto a later main-queue turn so that emitting
mid-mutation cannot re-enter the focus/selection path. That indirection is what fixed
the unbounded focus cycle in issue #5100. Focus itself still lands synchronously,
which is why the surrounding focusedPanelId assertions kept passing and only the
side-effect assertions failed.

Drain the main queue before reading focus history or notification state, matching what
the passing tests in these same two files already do.

testFocusHistoryMenuSnapshotCarriesFocusedTimestamp needed one more correction: a
.back snapshot lists where focus would return to, so its first item is the focus record
stamped by TabManager()'s own initial workspace, not by the later addWorkspace call.
The lower bound of the causal interval now reads before TabManager() so it actually
brackets the record under assertion.

These suites are not in any CI -only-testing allowlist, so nothing caught the drift.

* tests: inject focus-history timestamps

* tests: settle active focus-history coverage

---------

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 03:46:57 -07:00
austinpower1258 752f0f9eef Merge PR #8614 after resolving current main conflicts
Preserve the original PR head as ancestry while carrying the reviewed conflict resolution against current main.
2026-08-04 03:45:42 -07:00
austinpower1258 34ad688380 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:42:25 -07:00
austinpower1258 bfe921f9ec Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 03:41:33 -07:00
EJandejc3 0cc8445541 Open a browser at the end of the tab strip, not one slot short (#8705)
`openBrowser(insertAtEnd:)` passed a final position to `reorderTab`, which is
addressed in bonsplit insertion gaps. The end of the strip is `count`, not
`count - 1`, so the old value asked for the gap in front of the last tab and
left the new browser one slot short of the end.

It looked correct whenever exactly one tab followed the insertion point, since
the position and the gap agree there, which is why the existing two-tab test
did not catch it.

Before: TabManagerSurfaceCreationTests, 11 tests, 1 failure
After:  TabManagerSurfaceCreationTests, 11 tests, 0 failures

Co-authored-by: ejc3 <[email protected]>
2026-08-04 03:38:33 -07:00
austinpower1258 72aa0f0f73 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs
# Conflicts:
#	cmuxTests/BrowserConfigTests.swift
#	cmuxTests/BrowserPanelTests.swift
#	cmuxTests/OmnibarAndToolsTests.swift
2026-08-04 03:19:26 -07:00
Austin Wang 6d49edf927 Merge pull request #9576 from manaflow-ai/browser-headless-fixes
browser: integrate headless suite fixes against current main
2026-08-04 03:12:21 -07:00
austinpower1258 2d615e2b5b Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596 2026-08-04 03:11:29 -07:00
austinpower1258 66798bf699 Avoid nested type for sidebar link layout cache 2026-08-04 03:07:16 -07:00
austinpower1258 7ac4a2520a Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:06:30 -07:00
austinpower1258 4d3e14d90d Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 03:03:34 -07:00
austinpower1258 6639a2bd75 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:02:55 -07:00
Austin Wang c7b47c3e93 web: expose changelog versions to agent page variants (#9579) 2026-08-04 03:02:45 -07:00
austinpower1258 5dd6ea231f Revert "cmuxTests: avoid XCTest expectation host crashes"
This reverts commit b53005c56f.
2026-08-04 03:00:46 -07:00
austinpower1258 e7c4253c71 Retire ports when a terminal hibernates 2026-08-04 02:58:23 -07:00
8b8b1b0b87 Give the PR refresh run-loop test something real to observe (#8724)
* sidebar-git: give the PR refresh run-loop test something real to observe

testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in #2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.

Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.

The test injects a discovery that counts and sleeps. It resolves no slugs, which
keeps the refresh off the GitHub transport and away from `gh auth token`.

The test now makes two claims rather than three. The invocation count is the one
that can fail for a product reason, and it is the one that was broken. The
run-loop tick gap stays as a coarse guard against a seconds-long stall.

The old "discovery did not run on the main thread" check is gone, along with the
observation box that fed it. `repositorySlugs` is a nonisolated async requirement,
so SE-0338 runs it off the caller's actor however the refresh schedules it: the
check passed no matter what the product did, including if discovery were rewritten
to be awaited inline. A test that cannot fail is not evidence, and keeping it
would have implied coverage the test does not have.

The run-loop tick gap stays as a coarse guard, and its comment now says why it is
loose: 45 seeds times 30ms of injected blocking is 1.35s against a 2.0s ceiling, so
this test's own work cannot trip it. It fires only if the product adds a
multi-second main-thread stall on top.

The counter is renamed to RepositoryDiscoveryInvocationCounter, since it counts
discovery calls rather than command-runner calls, and the new TabManager parameter
carries a note that it overrides discovery for the pull-request refresh only.

* chore: prepare PR 8724 origin transfer

* test: transfer deterministic PR refresh coverage

---------

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:53:53 -07:00
Abdulaziz Albahar f52578acb9 Preserve Iroh sessions across relay policy refresh (#9538)
* test: prove relay policy refresh mutates once

* fix: preserve sessions across relay policy refresh

* fix: clean up failed relay policy activation
2026-08-04 04:53:46 -05:00
austinpower1258 622654ae0b Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 02:52:30 -07:00
b959519136 cmuxTests: stop the shortcut routing suite from taking the test host down (#8635)
One test in this suite has been killing the xctest host, which is worse than a red suite:
the host dies with no verdict and every suite batched with it loses its results too.

The evidence names the test. scripts/ci/cmux-unit-test-timings.json was generated from a
green main run by scraping per-test completion lines, and it holds 247 entries for this
suite. testWelcomeWindowSidebarShortcutsUseSharedToggleCommands is the only declared test
absent from it. A test that neither passes nor fails nor skips is one the host died inside.

That test is also the only place in this 12,000-line file that calls performClose on a
window it constructed, and the only closed window here that leaves AppKit's close-time
release enabled; the other twenty disable it, and the product does the same for its own
windows. The test holds the window through ARC while the delegate's window context and the
focus-capture swizzle hold weak references to it, so the deferred close drops the last
retain a runloop turn later and the process aborts rather than failing a test.

Separately, the one test that constructs a second AppDelegate restored AppDelegate.shared
but not the surface registry's route retirer, which init had pointed at the temporary
delegate and which the registry holds weakly. That left the retirer nil for the remainder
of the host, so later tests ran against a registry that never sweeps retired routes.

A third latent host kill stays for its own change: a key-event helper calls fatalError
instead of failing, and converting it needs a throwing signature at fourteen call sites,
which does not belong in the same diff as the crash it would obscure.

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:47:40 -07:00
austinpower1258 fcc476c9ca Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 02:47:04 -07:00
austinpower1258 a1726129e4 web: expose changelog versions to agent page variants 2026-08-04 02:46:26 -07:00
EJandejc3 e4bd9695d1 Show git status in the file explorer for repos reached through a symlink (#8577)
* file explorer: show git status for repos reached through a symlink

GitStatusProvider compared git's physical repo root (/private/var/...) against
the caller's explorer root spelled logically (/var, /tmp, or a symlinked project
dir) by raw string prefix, so every entry was dropped and the file explorer showed
no git status for any workspace behind a symlink. Resolve both roots to one spelling
for the containment check and emit keys under the caller's spelling so
FileExplorerStore lookups match. The ssh path keeps the caller's spelling on both
axes, so remote paths are never resolved against the local filesystem.

* file explorer: say when the root == "/" key branch is reached

---------

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:44:50 -07:00
64f7726b4f tests: stop the portal first-reveal fixture from killing the test host (#8689)
BrowserPortalFirstRevealScrollTests declares 16 tests. Run alone it completed 10
of them and restarted the app host three times, so the suite had no verdict and
anything sharing its host lost one too.

makeWindowFixture builds an NSWindow and three tests close it. AppKit releases a
window on close unless the owner opts out, and ARC still holds a strong reference,
so each of those closes over-releases and takes the process down. The count lines
up: exactly three tests call close(), and there were exactly three restarts. The
one test that builds its own window already sets the flag, so this was an omission
in the shared fixture rather than a deliberate difference.

The product does this everywhere it owns a window (BrowserPanel, the prewarmed
pool, the popup controller, ReleasingWindowController); only this fixture missed it.

Before: 3 restarts, 10 of 16 tests ran, ** TEST FAILED **
After:  0 restarts, 16 of 16 tests ran, ** TEST SUCCEEDED **

Both arms ran on the same worktree and the same warm derived-data path, one suite
per app host, with only this change between them.

Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
2026-08-04 02:43:37 -07:00
austinpower1258 3064ef4318 Revert "cmuxTests: retain markdown test windows across close"
This reverts commit 2ca500aa2f.
2026-08-04 02:42:06 -07:00
austinpower1258 97203addca Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 02:41:40 -07:00
EJandejc3 85fe23c44e CmuxAuthRuntime: wake the sign-in test waits on an event (#8644)
The HostBrowserSignInFlow harness waits spun on Task.yield() until their
condition held. Under CPU contention that is a bet on when the awaited task
gets scheduled, and the spinning loop competes with it for the same cores.
Running the package suites a few at a time was enough to lose the whole
target to

    HostBrowserSignInFlowTestSupport.swift:102: Fatal error: Timed out waiting
    for 1 host-browser session(s); got 0

since the timeout aborts the process and takes all 167 tests with it.

Raising the deadline does not fix that. With 48 busy loops on 16 cores, a
ten-second budget aborted the same way, only later. So each wait now suspends
until the fake it waits on resumes it: the session factory resumes session
waiters as it appends a session, the fake client resumes them as a currentUser
read parks on the closed user gate, and the gateable client resumes them once
an exchange has written its tokens or a clear has emptied the store. The
condition wait registers with the observation system instead, since the flow
and the coordinator are both @Observable. FlowFakeAuthClient's
storedAccessTokenDidPark() and ManualTestClock already worked this way.

The deadlines stay on as a net, so a genuine hang still reports by name rather
than suspending the run forever. They no longer bound a passing run.

Co-authored-by: ejc3 <[email protected]>
2026-08-04 02:41:00 -07:00
austinpower1258 516ac4b4cd Merge remote-tracking branch 'origin/main' into browser-headless-fixes 2026-08-04 02:39:39 -07:00
austinpower1258 2ca500aa2f cmuxTests: retain markdown test windows across close 2026-08-04 02:39:25 -07:00
austinpower1258 370a3ff944 cmuxTests: match mock socket thread QoS to waiters 2026-08-04 02:39:03 -07:00
austinpower1258 cb2506ab61 tests: fix omnibar overlay accumulator shadowing 2026-08-04 02:38:36 -07:00
austinpower1258 b554509c73 Add failing coverage for hibernated port retirement 2026-08-04 02:38:01 -07:00
austinpower1258 4636dd9353 Canonicalize TTY identities for port attribution 2026-08-04 02:30:16 -07:00
austinpower1258 729b26d568 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 02:10:30 -07:00
austinpower1258 5d25a5dd35 tests: harden browser headless regressions 2026-08-04 02:01:38 -07:00
cmux reload-cloud 8ef38547a1 Merge branch 'main' of https://github.com/manaflow-ai/cmux into HEAD 2026-08-04 02:00:53 -07:00
cmux reload-cloud c7374d1715 fix: use current Safari identity for Sheets 2026-08-04 02:00:26 -07:00
austinpower1258 2da63bafc8 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596 2026-08-04 01:58:08 -07:00
cmux reload-cloud fe19c02230 test: require current Safari identity for Sheets 2026-08-04 01:57:22 -07:00
austinpower1258 54854e33a4 Add failing coverage for full-path TTY attribution 2026-08-04 01:57:19 -07:00
austinpower1258 d806cf59d4 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 01:54:17 -07:00
austinpower1258 b53005c56f cmuxTests: avoid XCTest expectation host crashes 2026-08-04 01:47:34 -07:00
austinpower1258 9d4bf9a990 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:40:38 -07:00
austinpower1258 cd643fbcfb Merge remote-tracking branch 'origin/main' into browser-headless-fixes 2026-08-04 01:36:39 -07:00
austinpower1258 c7dcd52bcf Order test window for sidebar click dispatch 2026-08-04 01:36:22 -07:00
austinpower1258 f6b382aaee Guarantee enough scans after late port kicks 2026-08-04 01:35:43 -07:00
austinpower1258 fe88fd0ae1 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:34:19 -07:00
austinpower1258 c4c171bec9 cmuxTests: adapt current SSH host test to shared loop 2026-08-04 01:31:46 -07:00
austinpower1258 9fcfb5f498 Add failing coverage for late-burst port retirement
A single kick near the end of an existing scan burst can receive fewer than the three authoritative misses required to retire a stale port. Pin that idle-workspace timing path before changing the scheduler.
2026-08-04 01:20:51 -07:00
austinpower1258 a3e99e705c Fix merged sidebar action test fixture 2026-08-04 01:18:44 -07:00
austinpower1258 2f48ef1063 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:16:36 -07:00
austinpower1258 c315b8cf3c Exercise sidebar link clicks through the window 2026-08-04 01:11:55 -07:00
austinpower1258 8eff531da8 cmuxTests: adapt current CLI socket tests to shared loop 2026-08-04 01:10:37 -07:00
austinpower1258 625712e757 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 01:10:02 -07:00
austinpower1258 c2dbb5aba3 Reconcile sidebar link review feedback 2026-08-04 01:09:32 -07:00
austinpower1258 64dc135ec3 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:04:44 -07:00
austinpower1258 a70bd20b78 Merge remote-tracking branch 'origin/main' into fix/thirteen-test-suite-repairs 2026-08-04 00:56:25 -07:00
austinpower1258 9f88b6d71a Keep TCP port evidence complete across lsof warnings
Pass -w to the PID-scoped TCP listener query so unrelated filesystem mount warnings cannot poison every scan. Actual command failures, timeouts, malformed output, and PID-scoped uncertainty remain incomplete.

This is the green half for the persistent-warning regression in 6b54e60f76.
2026-08-04 00:55:13 -07:00
cmux reload-cloud 1b2763d4ce fix: separate Sheets transport and WebKit identities 2026-08-04 00:54:59 -07:00
austinpower1258 6b54e60f76 Add failing coverage for persistent lsof warnings
Issue #9152 reports a Time Machine filesystem warning on every lsof invocation. Model that warning in the full port lifecycle harness and prove PID-scoped TCP evidence must remain authoritative, while tightening the existing process-identity and retry test seams.

This is the tests-only red half of the regression pair.
2026-08-04 00:53:32 -07:00
cmux reload-cloud a224b9a5d5 test: require supported Sheets transport identity 2026-08-04 00:46:31 -07:00
cmux reload-cloud 6d8ba314a9 Merge branch 'main' of https://github.com/manaflow-ai/cmux into HEAD 2026-08-04 00:45:19 -07:00
austinpower1258 ec67c509f4 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596
# Conflicts:
#	Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift
#	cmuxTests/SidebarAppKitRowCellTests.swift
2026-08-04 00:44:43 -07:00
cmux reload-cloud db30cc93eb Merge remote-tracking branch 'origin/main' into HEAD 2026-08-03 23:47:11 -07:00
cmux reload-cloud cd90d0b9f6 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	cmuxTests/BrowserUserAgentPolicyWebKitTests.swift
2026-08-03 23:42:56 -07:00
cmux reload-cloud c3cc0d1522 revert: remove #9483 browser policy changes
Restore the original navigation-action API and remove the regression test that encoded the same-URL stale-identity fallback. Keep PR #9482 nil/empty normalization as the sole convergence rule.
2026-08-03 23:40:34 -07:00
cmux reload-cloud 6770f596be revert: remove stale browser identity fallback 2026-08-03 22:03:28 -07:00
cmux reload-cloud 1c906c1a2d test: reject stale Sheets identity fallback 2026-08-03 21:53:07 -07:00
cmux reload-cloud c5060a8f83 Merge branch 'main' of https://github.com/manaflow-ai/cmux into HEAD
# Conflicts:
#	Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift
2026-08-03 21:42:23 -07:00
austinpower1258 f170ffef77 fix: make browser identity replay idempotent 2026-08-03 16:42:31 -07:00
austinpower1258 c56ee7d57f test: reproduce Google Sheets identity replay 2026-08-03 16:42:31 -07:00
Myk MelezandClaude Fable 5 15ca94fd16 Pin mixed vanished/unreadable TTY diagnostics as incomplete
Review claimed a vanished TTY could launder another terminal's failed
diagnostic into a complete scan. It cannot: non-ENOENT diagnostics never
remove a terminal from the retry set, so completeness requires either a
clean re-query or explicit ENOENT for every terminal. Pin the exact
mixed case: ENOENT for one TTY plus Permission denied for another
re-queries only the unreadable terminal and stays incomplete when its
diagnostic persists.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C
2026-08-02 15:58:45 -07:00
austinpower1258 6340fce385 feat: add live pane attention color editor 2026-07-31 23:46:43 -07:00
cmux reload-cloud d7a2c873f1 Merge remote-tracking branch 'origin/main' into issue-9065-remove-cgwindowlistcreateimage 2026-07-31 23:40:36 -07:00
cmux reload-cloud 673bb9b65c fix: serialize screenshot capture safely 2026-07-31 23:38:34 -07:00
cmux reload-cloud a605f2eb49 fix: preserve screenshot fallback and overlay semantics 2026-07-31 23:17:02 -07:00
cmux reload-cloud 5670958972 fix: bound permission-free screenshot compositing 2026-07-31 22:58:40 -07:00
austinpower1258 3758434f05 test: preserve notification parsing after invalid flash color 2026-07-31 22:50:55 -07:00
austinpower1258 287529a038 Merge remote-tracking branch 'origin/main' into feat/configurable-pane-flash-color 2026-07-31 22:45:11 -07:00
cmux reload-cloud cc9196c198 fix: composite own window snapshots without capture permission 2026-07-31 22:44:12 -07:00
cmux reload-cloud 79f7124312 Merge origin/main into issue-9065-remove-cgwindowlistcreateimage 2026-07-31 21:47:02 -07:00
Myk MelezandClaude Fable 5 92fa684218 Document why English ENOENT matching is locale-safe on Darwin
Review asked to pin LC_ALL on the ps invocation because vanishedTTYNames
matches the English strerror(ENOENT) text. Darwin libc ships no localized
message catalogs, so ps emits this exact string under any locale (verified
empirically with LC_ALL=ja_JP.UTF-8); record that constraint instead of
widening the CommandRunning API for an unreachable failure mode.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C
2026-07-31 17:21:29 -07:00
Myk MelezandClaude Fable 5 a5dc5afa06 Retire stale ports despite vanished TTYs, privileged owners, and zombies
One fix per mechanism the failing tests pin:

- `runPS` drops the terminals `ps` reports as ENOENT and retries with
  the rest, bounded so a pty churning mid-scan cannot spin. Terminals
  that are all gone report authoritative emptiness — a freed pty can
  hold no process — so their stale badges clear too, while any other
  diagnostic still yields incomplete, which retains ports rather than
  dropping them on weak evidence. Vanished terminals are matched by
  device name, so the two-device diagnostic form (`/dev/ttyX and
  /dev/X`) and TTYs registered by full device path are recognized, and
  "every terminal is gone" outranks the retry budget so authoritative
  emptiness does not depend on which attempt the final pty closed
  during.
- Birth identities are read through `sysctl(KERN_PROC_PID)` instead of
  `proc_pidinfo`. It reports the same birth timestamp for any live
  process regardless of owner, and still reports nothing for an exited
  PID, so recycling detection keeps working and now covers privileged
  PIDs it previously had to guess about. `SZOMB` is rejected
  explicitly: sysctl also describes an exited-but-unreaped process, and
  session restore treats a matching identity as proof the agent is
  alive.
- `PIDPresence` routes through the same process-table read that
  supplies birth identities, so liveness and identity cannot drift
  apart, and an unreaped process reads as absent to every caller
  weighing whether it might still own something.
  `Workspace.agentPIDProcessIdentity(pid:)` reads through that single
  reader as well instead of keeping its own `proc_pidinfo` copy.

The tests added here pin behavior this fix introduces rather than the
original bug: zombie identities reading as absent, diagnostic forms
that must not trigger the retry, and the retry budget staying
incomplete when a TTY never becomes scannable.

Fixes #9152.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
2026-07-31 16:53:39 -07:00
Myk MelezandClaude Fable 5 6ddcc4c0e2 Add failing tests for ports that can never retire
Sidebar port badges accumulate dead ports for the lifetime of the app
because three independent mechanisms each leave a panel's scan
permanently incomplete, and PortScanSnapshotReconciler treats incomplete
scans as non-evidence that only ever unions ports:

- BSD `ps` aborts an entire batched `-t` query when any listed terminal
  device is gone, so one closed pty makes every panel's port scan look
  incomplete.
- `proc_pidinfo` refuses any process whose effective UID differs from
  ours, so the root-owned `/usr/bin/login` heading every terminal reads
  as unidentifiable, files under `incompletePIDs`, and scores its panel
  incomplete on every scan.
- A zombie answers `kill(pid, 0)` like a running process while holding
  no readable identity — the same incompleteness, reachable through any
  unreaped child, though a zombie has exited and can hold no socket.

An end-to-end test also drives the real scanner — registerTTY, kick,
coalesce, burst, reconcile, publish — and asserts a port is retired once
its process stops listening, since these bugs left every stage passing
its own unit test while the feature was fully broken. Identity and
presence deliberately stay on the real providers: an earlier draft
injected them and passed against the broken tree.

Covers github.com/manaflow-ai/cmux/issues/9152.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
2026-07-31 16:53:39 -07:00
ejc3 8358a7df40 cmuxTests: read the saved XDG_STATE_HOME through getenv, not ProcessInfo
ProcessInfo.processInfo.environment is captured once and does not reflect setenv calls
made after first access, so saving the prior value through it and restoring in a defer
writes back a stale snapshot whenever an earlier test changed the variable at runtime.
getenv reads the live value, which is also the level the setenv/unsetenv pair below
operates at.
2026-07-30 23:33:44 -07:00
ejc3 8c30fb259a cmuxTests: restore the browser-profile selection this suite pins
BrowserPanelRemoteStoreTests pins the built-in default profile in setUp because the
selection is persisted in UserDefaults and a leftover profile from another test breaks
its store-scoping checks. Pinning without restoring commits the same offense in the
other direction: every suite that runs after this one inherits the built-in default.
Save the selection in setUp and put it back in tearDown.
2026-07-30 23:33:44 -07:00
ejc3 9e416c0b19 cmuxTests: repair four more suites, including two shortcut tests pinning a retired model
KeyboardShortcutSettingsFileStoreTests asserted the older model where a shortcut
saved through Settings outranks one bound in cmux.json. The product deliberately
went the other way: a file-managed action is authoritative and read-only, with
file-first lookup, refused writes, and a Settings row that reports itself managed
instead of editing. The tests now pin that, which is what they were for.

RemoteTmuxMirrorFeedForwardTests, RenderableSystemSymbolTests and
TabManagerFocusedNotificationIndicatorTests were failing on their own fixtures.

Each run before and after on a macOS builder, on this branch with the sixteen
already here, so the whole set is verified together rather than in isolation.
2026-07-30 23:33:44 -07:00
ejc3 22b5e66ce6 cmuxTests: repair three more suites, one of which was killing the test host
MarkdownPanelTests fulfilled a one-shot XCTestExpectation twice: WebKit can
report a provisional failure and then a finish for a single load, and the second
fulfill() raises XCTest's API-violation NSException from inside a suspended
await fulfillment. That does not fail a test, it takes the shared app host down
and every suite batched with it, so this one was corrupting other suites'
results as well as its own. The load delegate now settles once.

CLIHookNoResponseTests and CmuxDurableDeepLinkRestoreTests were failing on their
own fixtures in the same way as the thirteen already in this branch.

Each verified on a macOS builder before and after, on the combined branch rather
than in isolation, so the three land on top of the existing thirteen with the
whole set re-run.
2026-07-30 23:33:43 -07:00
ejc3 a003053402 cmuxTests: repair thirteen suites that were failing on their own fixtures
Thirteen suites in the pre-existing red set fail for reasons inside the tests,
not the product. Each one here was run before and after on a macOS builder and
goes from a failing verdict to a passing one; no product behavior changes and no
assertion is weakened or deleted.

The recurring shapes:

- Oracles that could be satisfied before the thing under test had happened, so
  the wait returned early and the assertion read startup state. These now read
  live state that only exists after the operation commits.
- Fixtures that could not reach the state their test described, so an assertion
  waited on a condition that was unreachable rather than merely slow.
- Expectations pinned to a machine-dependent value (a resolver path, a config
  directory, a bundled binary layout) instead of deriving it the way the product
  does.
- Shared global state left behind for the next test in the same host.

Verified per suite rather than in bulk: applied to a clean tree off the CmuxGit
compile fix, then each suite run through the app host. All thirteen report a
passing suite verdict with a non-zero test count.
2026-07-30 23:33:37 -07:00
ejc3 f821f78784 browser: fix headless-broken Browser test suites and the bugs they caught
The Browser* suites in cmuxTests had 18 failures across 9 suites under a local
headless `xcodebuild test`. Three were real product bugs the tests had been
catching all along; the rest were tests asserting behavior the product had
deliberately moved away from, or waiting on the wrong signal.

Product fixes:

- A panel constructed with a URL but `renderInitialNavigation: false` kept the
  `.newTab` lifecycle state it was born with. The deferred path returns from
  `init` before any visibility or navigation transition runs, and nothing else
  seeds the state, so a restored deferred tab reported itself as a new tab.
  Seed it in `init` for both the request and URL paths.

- The legacy `browserForcedDarkModeEnabled` migration could never run. Fallback
  registration goes into the process-wide registration domain, so once any panel
  bootstrapped defaults, `browserThemeMode` always resolved to a value and
  `BrowserThemeSettings.mode(defaults:)` took its early return instead of
  migrating. Users upgrading with forced dark mode on silently lost the setting.
  The key does not need a registered fallback: the accessor already falls back to
  `defaultMode` and the SwiftUI binding carries its own default.

- A visible portal slot whose anchor was removed outright kept rendering against
  the dead anchor. The off-window-reparent branch already distinguished an anchor
  that is still parented (drag churn, keep it on screen) from one that is not,
  but the following line preserved the slot unconditionally, so the orphaned case
  never reached the hide-while-retrying path.

Test fixes:

- Under-page background and hidden-discard-delay expectations predated the
  behavior they assert: the terminal color is composited over the window
  background rather than alpha-blended, and an out-of-range stored delay is
  rejected in favor of the default rather than clamped to the maximum.

- The discard tests waited on `webView.isLoading` while the discard gate also
  reads the panel's own `isLoading`, which stays set for the minimum indicator
  duration after WebKit finishes. Wait for the condition the gate actually reads,
  and report the blockers when a discard is refused.

- `waitForBrowserPanel` accepted the omnibar URL, which the panel publishes as
  soon as a navigation is requested and before `isLoading` rises, so it could
  return before the page loaded at all. Wait for the web view's committed URL.

- The remote-store tests assumed the built-in default profile was ambient, but a
  panel without an explicit profile adopts the last-used one, and that selection
  is persisted. Pin the default profile, and delete temporary test profiles so
  they stop accumulating in the shared defaults.

- The portal reveal test still required a visibility change to cycle WebKit's
  `_exitInWindow`/`_enterInWindow` pair, which was removed on purpose because
  cycling it fires visibilitychange and broke the DevTools pane across workspace
  switches. It now asserts that invariant instead.

- The omnibar suggestions hit test built its point by flipping y by hand, but
  `hitTest` takes superview coordinates and the flipped hosting view disagrees
  with its unflipped slot about y. Convert through AppKit and host the slot in a
  window so the SwiftUI overlay answers hit tests.
- `testBackgroundPreloadIsConsumedByInitialNavigation` built an NSWindow with
  AppKit's default `isReleasedWhenClosed` and closed it, so the window was
  over-released and XCTest's memory checker walked the freed object at teardown
  and took the test host down with a SIGSEGV in `objc_release`. The host restart
  was also hiding tests: the suites now report 54 tests instead of 34.
2026-07-30 23:21:53 -07:00
austinpower1258 03f37f60c6 fix: skip invalid screenshot window numbers 2026-07-28 17:08:36 -07:00
austinpower1258 527d503432 test: cover invalid screenshot window numbers 2026-07-28 17:08:06 -07:00
austinpower1258 f1667eabb6 fix: latch compositor after capture timeout 2026-07-28 14:54:31 -07:00
austinpower1258 49bc794003 Merge remote-tracking branch 'origin/main' into issue-9065-remove-cgwindowlistcreateimage 2026-07-28 14:46:20 -07:00
austinpower1258 8ed80bf636 fix: bound composited screenshot failures 2026-07-28 14:41:25 -07:00
austinpower1258 947f4d8584 Merge remote-tracking branch 'origin/main' into issue-9065-remove-cgwindowlistcreateimage 2026-07-28 14:04:26 -07:00
austinpower1258 2b12a95cf5 fix: bound composited screenshot routing 2026-07-28 14:03:30 -07:00
austinpower1258 25e8907da6 fix: type AppKit screenshot fallback 2026-07-28 13:53:26 -07:00
austinpower1258 dbb34c1efc fix: preserve composited window screenshots 2026-07-28 13:51:28 -07:00
austinpower1258 f584f09353 Merge remote-tracking branch 'origin/main' into issue-9065-remove-cgwindowlistcreateimage 2026-07-28 13:37:52 -07:00
austinpower1258 2c955f6a86 test: wait for rendered screenshot content 2026-07-28 13:14:57 -07:00
austinpower1258 e8d36004e2 test: match XCTest activation assertion 2026-07-28 13:06:34 -07:00
austinpower1258 ba63392574 test: harden screenshot regression setup 2026-07-28 12:50:14 -07:00
austinpower1258 cdc7e50c0e fix: replace legacy window screenshot capture 2026-07-28 12:47:23 -07:00
austinpower1258 26f31b7306 test: tolerate background UI test launch 2026-07-28 12:39:58 -07:00
austinpower1258 b9b51b2b6d test: cover window screenshot socket output 2026-07-28 12:20:22 -07:00
mcorcelleandClaude Opus 4.8 7aeb4e3a60 Address review: strict hex validation, matching preview, alpha test
- resolvedColor now checks the `#` prefix and 7-character length before
  NSColor(hex:), which tolerates a missing prefix. Keeps the runtime in
  step with the schema's `colorHexOrNull` (#RRGGBB, no alpha).
- colorRow takes a `fallback` preview color, defaulting to the existing
  cmuxAccentColor(). Pane Flash passes systemBlue so the settings swatch
  shows the color the ring actually renders when unset.
- Adds testFlashColorFallsBackWhenHexCarriesAlpha covering #RRGGBBAA.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-27 14:03:52 +02:00
ejc3 4a11af6f08 cmuxTests: give the CLI mock servers an owned lifecycle and one shared loop
Follow-up cleanup on the mock control-socket rework.

The accept loops had no way to stop. Closing the listener FD does not wake a
thread already parked in poll/accept on Darwin, so every server leaked its
thread for the life of the test process — worse than the old bounded loops,
which at least self-terminated once they had accepted their quota. The registry
now owns each loop: it pairs the listener with a private stop pipe, and
`stop(listenerFD:)`/`stopAll()` signal the loop and join it. Both suites reap
their loops in tearDown.

Registry hardening:
- Hold the lock across retire-old and register-new. Two concurrent starts on one
  FD could each observe the same predecessor, each wait for it, then each spawn a
  loop, putting two loops back on one listener — the stealing bug returning by
  another door. The loop threads never take the lock (they only signal
  completion), so holding it across the join can't deadlock.
- The registry, not the loop, owns the stop pipe for the loop's whole life, so a
  stop byte can never land in an unrelated descriptor that reused the number.
- Check pipe(); without a stop pipe a loop would be unstoppable, so fail loudly
  rather than start one. A loop that ignores its stop byte now fails the test
  instead of being left running.

Consolidation:
- One `cliMockServeLineFramedConnection` reader replaces four copies of the
  read/frame/respond loop, and one `cliMockWriteAll` replaces the duplicated
  partial-write handling. `CLIMockOnceFlag` replaces the two identical latches.
- Drop `connectionCount`/`connectionLimit` from the servers that no longer bound
  connections, along with the dead default, and rewrite the comments that still
  described a fixed pool. Same-named helpers owned by other suites keep their
  live parameters.
2026-07-24 21:57:33 -07:00
ejc3 71eea9f69f cmuxTests: make CLI mock control sockets headless-robust; refresh vm-new expectations
The CLI hook integration suites drive the bundled cmux helper as a subprocess
against a mock control socket. Headless (piped stdio, no controlling TTY) the
helper always falls back to a `system.top` agent-process lookup on a second,
dedicated control connection because caller-TTY resolution can't succeed. The
mocks accepted only one connection, so that extra connection was starved: hooks
stalled for the 2s socket timeout, resolution fell back to unresolved routing,
and assertions saw the wrong RPC sequence (or empty output after a 5s process
timeout).

Rework the mock accept path so every connection the helper opens is serviced:

- A single poll-based accept loop per listener FD dispatches each connection to
  its own handler, and a new server on the same FD supersedes (stops and joins)
  the previous one so a leftover loop can't steal the next hook's connection and
  fulfill the wrong expectation.
- The loops run on raw threads instead of GCD queues. A blocking accept() parked
  on a GCD worker ties it up for the whole test; a suite that opens a server per
  hook drained the shared GCD pool that runProcess needs for its stdout/stderr
  readers and exit waiter, which looked exactly like the helper hanging.

Also:
- Bind the tmux-compat-env test's control socket under a short /tmp path. The
  AF_UNIX sun_path limit is 104 bytes and this machine's temp dir alone overflows
  a socket nested under it.
- Update the default-freestyle vm-new tests to expect vm.attach_info: `vm new`
  uses forceSSH:false, which resolves through vm.attach_info (already covered by
  the SSH startup suites), not the older vm.ssh_info path.
2026-07-24 21:57:33 -07:00
austinpower1258 80723789d0 Merge remote-tracking branch 'origin/main' into fix/atd-sidebar-link-click-8596
# Conflicts:
#	Packages/macOS/CmuxGit/Sources/CmuxGit/Probe/GitHubPullRequestRequestCoordinator.swift
2026-07-21 18:53:13 -07:00
austinpower1258 83b3f31a02 Restrict sidebar description link schemes 2026-07-21 18:44:20 -07:00
austinpower1258 53880f2426 Cover wrapped sidebar description links 2026-07-21 18:14:33 -07:00
austinpower1258 00ab332503 Address sidebar link review feedback 2026-07-21 17:56:39 -07:00
austinpower1258 7776008d6a Tighten sidebar description link hit bounds 2026-07-21 17:48:11 -07:00
austinpower1258 acdd03d1c1 Open AppKit sidebar description links 2026-07-21 17:30:06 -07:00
austinpower1258 87e8565e1a Add sidebar description link click regression 2026-07-21 17:30:02 -07:00
austinpower1258 cee2d91102 Fix CmuxGit coordinator initializer 2026-07-21 17:29:54 -07:00
mcorcelleandClaude Opus 4.8 6725ac6502 Make pane flash and attention ring color configurable
The pane attention flash and unread notification ring were hardcoded to
systemBlue via a single-case accent enum, so the one element that signals
"this pane needs input" could not be themed while everything around it
(workspace badge, selection highlight, terminal theme) could.

Add `notifications.paneFlashColor`, a nullable hex reusing the existing
`colorHexOrNull` schema def and `parseNullableHex` validation. Null keeps
the built-in systemBlue, so default appearance is unchanged.

Because every consumer resolves through `presentation.accent.strokeColor`,
resolving the accent from settings covers the notification ring, the flash,
the tmux pane overlay and the SwiftUI ring view in one place.

Surfaced in Settings under Workspace Colors, directly below Notification
Badge, using the existing colorRow helper.

Refs #8560

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-21 16:53:43 +02:00
962 changed files with 94183 additions and 11340 deletions
+30
View File
@@ -151,6 +151,18 @@ jobs:
- name: Validate app-host xcodebuild attempt budget
run: ./tests/test_ci_app_host_xcodebuild_attempts.sh
- name: Validate app-host user configuration isolation
run: python3 tests/test_ci_app_host_home_isolation.py
- name: Validate app-host identity and cleanup confirmation
run: bash tests/test_ci_app_host_identity.sh
- name: Validate app-host process receipts
run: bash tests/test_ci_app_host_processes.sh
- name: Validate isolated app-host home cleanup
run: bash tests/test_ci_app_host_home_cleanup.sh
- name: Validate cmux profiling support scripts
run: ./tests/test_start_cmux_profiling.sh
@@ -259,6 +271,7 @@ jobs:
- name: Validate SwiftPM lockfile policy
run: |
python3 tests/test_package_resolved_policy_remote_inputs.py
python3 tests/test_check_package_resolved_policy.py
python3 scripts/check-package-resolved-policy.py
@@ -268,6 +281,9 @@ jobs:
- name: Validate sidebar lazy-layout guard
run: python3 tests/test_ci_sidebar_lazy_layout_guard.py
- name: Validate focused Dock shortcut routing guard
run: python3 tests/test_dock_shortcut_routing_guard.py
- name: Validate bash prompt bootstrap composes with user PROMPT_COMMAND (starship)
run: python3 tests/test_issue_5164_starship_prompt_composition.py
@@ -471,6 +487,10 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
env:
# This independent job-level marker makes every app-host wrapper fail
# closed if a setup step or environment handoff loses either redirect.
CMUX_CI_APP_HOST_ISOLATION_REQUIRED: "1"
CMUX_APP_HOST_SHARD: ${{ matrix.shard }}
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
CMUX_SKIP_ZIG_BUILD: "1"
@@ -544,6 +564,9 @@ jobs:
mkdir -p "$DERIVED_DATA_PATH"
echo "CMUX_DERIVED_DATA_PATH=$DERIVED_DATA_PATH" >> "$GITHUB_ENV"
- name: Prepare isolated app-host home
run: scripts/ci/prepare-app-host-home.sh
- name: Resolve Swift packages
run: |
set -euo pipefail
@@ -1089,6 +1112,10 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_campfire_extension_install.py
- name: Clean up isolated app-host home
if: ${{ always() }}
run: scripts/ci/run-in-console-session.sh scripts/ci/cleanup-app-host-home.sh
tests:
name: tests
# Aggregate gate for the test/build suites in this workflow. Required by
@@ -1228,6 +1255,7 @@ jobs:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
retention-days: 1
- name: Retry universal Ghostty CLI helper upload
if: steps.upload-ghostty-cli-helper.outcome == 'failure'
@@ -1236,6 +1264,7 @@ jobs:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
retention-days: 1
overwrite: true
- name: Select Xcode
@@ -1312,6 +1341,7 @@ jobs:
CmuxControlSocket
CmuxFoundation
CmuxGit
CmuxNotifications
CmuxSettings
CmuxSettingsUI
CmuxTerminal
+25 -4
View File
@@ -94,7 +94,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y binutils clang libclang-dev pkg-config
sudo apt-get install -y binutils clang libclang-dev musl-tools pkg-config
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
@@ -120,7 +120,7 @@ jobs:
run: rustup target add ${{ matrix.target }}
- name: Install cargo-zigbuild
if: matrix.cross == true
if: matrix.cross == true && runner.os == 'Linux'
shell: bash
run: cargo install --locked [email protected]
@@ -145,8 +145,8 @@ jobs:
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (cross)
if: matrix.cross == true
- name: Build cmux-tui (Linux cross)
if: matrix.cross == true && runner.os == 'Linux'
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
@@ -166,6 +166,27 @@ jobs:
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (macOS cross)
if: matrix.cross == true && runner.os == 'macOS'
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
working-directory: cmux-tui
shell: bash
run: |
# Xcode's macOS SDK natively supports cross-architecture builds.
# cargo-zigbuild cannot resolve SDK frameworks when the host is arm64.
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Stage binary
shell: bash
run: |
+172 -26
View File
@@ -42,9 +42,17 @@ jobs:
npm run build
npm test
valgrind-leak-check:
valgrind-leak-check-shard:
name: valgrind-leak-check (${{ matrix.shard }})
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
# The core, remote, and application suites are intentionally serialized
# under instrumentation. Isolate them so none can consume another test
# binary group's runtime budget.
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [core, remote, tui, remainder]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -76,32 +84,61 @@ jobs:
# SIMD codegen stays within what valgrind's instruction emulation
# supports (see crates/ghostty-vt-sys/build.rs).
CMUX_GHOSTTY_VT_ZIG_CPU: baseline
VALGRIND_SHARD: ${{ matrix.shard }}
run: |
mkdir -p target
cargo test --workspace --locked --no-run --message-format=json > target/cargo-test-binaries.jsonl
python3 <<'PY'
import json
import os
import re
import sys
shard = os.environ["VALGRIND_SHARD"]
known_shards = {"core", "remote", "tui", "remainder"}
if shard not in known_shards:
raise SystemExit(f"unknown Valgrind shard: {shard}")
def shard_for(executable):
name = os.path.basename(executable)
if re.fullmatch(r"cmux_tui_core-[0-9a-f]+", name):
return "core"
if re.fullmatch(r"cmux_remote-[0-9a-f]+", name):
return "remote"
if re.fullmatch(r"cmux_tui-[0-9a-f]+", name):
return "tui"
return "remainder"
seen = set()
selected = []
with open("target/cargo-test-binaries.jsonl", "r", encoding="utf-8") as messages:
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
for line in messages:
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if not message.get("profile", {}).get("test"):
continue
executable = message.get("executable")
if not executable or executable in seen:
continue
seen.add(executable)
print(executable, file=output)
for line in messages:
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if not message.get("profile", {}).get("test"):
continue
executable = message.get("executable")
if not executable or executable in seen:
continue
seen.add(executable)
if shard_for(executable) == shard:
selected.append(executable)
if not seen:
raise SystemExit("cargo did not report any test binaries")
print(f"Collected {len(seen)} test binaries", file=sys.stderr)
if not selected:
raise SystemExit(f"Valgrind shard {shard} selected no test binaries")
with open("target/valgrind-test-binaries.txt", "w", encoding="utf-8") as output:
for executable in selected:
print(executable, file=output)
print(
f"Valgrind shard {shard} selected {len(selected)} of {len(seen)} test binaries",
file=sys.stderr,
)
PY
- name: Run test binaries under valgrind
@@ -111,25 +148,99 @@ jobs:
# otherwise-correct build; the guarded regression (events serialized
# behind a 100ms read poll) inflates far past this bound anyway.
CMUX_TEST_WS_LATENCY_BUDGET_MS: "2000"
# Process-exit and PTY-reader tests also use bounded polling. Keep
# their normal deadlines strict while allowing for instrumentation.
# Scale test-fixture deadlines and their observation windows together
# under instrumentation. Production defaults and normal CI remain strict.
CMUX_TEST_TIMEOUT_SCALE: "4"
# Retain the full 128 MiB fairness workload under Valgrind with
# explicit instrumentation-only bounds. Normal CI stays strict.
CMUX_TEST_PERF_INSTRUMENTED: "valgrind"
run: |
while IFS= read -r bin; do
[ -n "$bin" ] || continue
echo "Running valgrind for $bin"
if ! valgrind \
run_valgrind() {
local bin="$1"
shift
valgrind \
"${valgrind_args[@]}" \
--error-exitcode=1 \
--leak-check=full \
--show-leak-kinds=definite \
--errors-for-leak-kinds=definite \
--track-origins=yes \
-- "$bin"; then
-- "$bin" "$@"
}
while IFS= read -r bin; do
[ -n "$bin" ] || continue
echo "Running valgrind for $bin"
valgrind_args=(--track-origins=yes)
test_args=()
case "$(basename "$bin")" in
pty-*|cmux_tui_core-*|cmux_tui-[[:xdigit:]]*)
# These tests own bounded worker pools, PTYs, sockets, and
# deadline-sensitive readers. Valgrind serializes their CPU
# work internally, so test-harness parallelism only creates
# scheduler starvation and wall-clock timeout races.
test_args+=(--test-threads=1)
;;
cmux_remote-[[:xdigit:]]*)
# Remote-runtime tests also own real schedulers, sockets, and
# deadline checks. Serial execution prevents the instrumented
# harness from starving its own observation deadlines.
test_args+=(--test-threads=1)
;;
terminal_host_recovery-*)
# Valgrind instruments this client harness but not the hidden
# terminal-host child it launches. The normal-speed child can
# fill the socket while the instrumented reader is descheduled,
# correctly triggering the production stalled-client timeout
# mid-frame. Normal Linux and macOS CI retain this ordering test;
# every other recovery case remains under Valgrind.
test_args+=(--skip exit_follows_all_final_pty_bytes_on_the_live_stream)
;;
direct_wss_e2e-*|relay_wss_diagnostic-*)
# ring's AES-GCM backend exposes initialized output through a
# partially initialized SIMD buffer. Valgrind reports its
# padding at Rustls writev. Keep leak and address checks for
# TLS integration binaries while scoping undefined-value
# suppression to those binaries.
valgrind_args=(--undef-value-errors=no)
;;
esac
if [[ "$(basename "$bin")" == cmux_remote-[[:xdigit:]]* ]]; then
# Iroh's Rustls/ring and noq UDP paths expose initialized data
# through buffers with uninitialized SIMD or sockaddr padding.
# Run only those tests without undefined-value diagnostics while
# retaining address and leak checks. Every other remote test
# keeps the complete Valgrind diagnostic set.
if ! run_valgrind "$bin" --skip 'provider::iroh::' "${test_args[@]}"; then
echo "Valgrind failed for $bin outside the Iroh provider" >&2
exit 1
fi
valgrind_args=(--undef-value-errors=no)
if ! run_valgrind "$bin" 'provider::iroh::' "${test_args[@]}"; then
echo "Valgrind failed for $bin in the Iroh provider" >&2
exit 1
fi
continue
fi
if ! run_valgrind "$bin" "${test_args[@]}"; then
echo "Valgrind failed for $bin" >&2
exit 1
fi
done < target/valgrind-test-binaries.txt
valgrind-leak-check:
name: valgrind-leak-check
if: always()
needs: valgrind-leak-check-shard
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 2
steps:
- name: Require every Valgrind shard
env:
SHARD_RESULT: ${{ needs.valgrind-leak-check-shard.result }}
run: test "$SHARD_RESULT" = success
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os == 'macos' && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || 'ubuntu-latest' }}
@@ -195,6 +306,17 @@ jobs:
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Install TypeScript binding dependencies
working-directory: cmux-tui/bindings/typescript
run: npm ci --no-audit --no-fund
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
@@ -226,11 +348,35 @@ jobs:
working-directory: cmux-tui
run: cargo build -p cmux-tui
- name: Resolve Zig SDK version
id: zig-sdk-version
shell: bash
run: |
version="$(
sed -nE 's/^[[:space:]]*\.minimum_zig_version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' \
cmux-tui/bindings/zig/build.zig.zon | head -1
)"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid Zig SDK version: $version" >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install Zig for SDK conformance
env:
ZIG_REQUIRED: ${{ steps.zig-sdk-version.outputs.version }}
ZIG_FORCE_LOCAL_INSTALL: "1"
run: ./scripts/install-zig-ci.sh
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
run: |
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
python3 cmux-tui/bindings/conformance/runner.py
- name: Binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
run: |
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
windows-experimental:
name: windows experimental (x86_64-gnu)
+9 -2
View File
@@ -452,7 +452,7 @@ jobs:
# The demo profile is fetched from the ASC API by name instead of a
# repository secret, so regenerating it in the developer portal
# needs no secret rotation. Same credentials the upload uses.
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution")"
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution Push")"
elif [ "$IOS_BETA_PROFILE_TYPE" = "internal" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
else
@@ -475,9 +475,16 @@ jobs:
echo "$IOS_BETA_PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $IOS_BETA_EXPECTED_APP_ID)" >&2
exit 1
fi
# TestFlight uses production APNs. Both capabilities must be present
# in the installed profile or export can silently strip them.
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '${APS_ENVIRONMENT:-<absent>}', expected 'production'" >&2
exit 1
fi
TIME_SENSITIVE="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:com.apple.developer.usernotifications.time-sensitive" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ "$TIME_SENSITIVE" != "true" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile com.apple.developer.usernotifications.time-sensitive is '${TIME_SENSITIVE:-<absent>}', expected 'true'" >&2
exit 1
fi
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
+1
View File
@@ -59,6 +59,7 @@ jobs:
name: cmux-ghostty-cli-helper
path: ghostty-cli-helper/ghostty
if-no-files-found: error
retention-days: 3
build-sign-notarize:
needs: build-ghostty-cli-helper
+1 -1
View File
@@ -246,5 +246,5 @@ jobs:
with:
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
path: artifact/
retention-days: 3
retention-days: 1
if-no-files-found: error
+5 -1
View File
@@ -609,12 +609,16 @@ jobs:
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
retry_missing_project=()
if [[ "$PACKAGE" == "cmux-sidebar" ]]; then
retry_missing_project=(--retry-missing-project)
fi
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--retry-missing-project \
"${retry_missing_project[@]}" \
--wait-seconds 300 \
--require-match
sleep 1
+11 -4
View File
@@ -167,6 +167,7 @@ jobs:
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--require-dist-tag latest \
--publisher owner \
--artifact "${packages[0]}"
@@ -258,7 +259,7 @@ jobs:
exit 1
}
echo "npm lifecycle scripts are disabled in the credentialed publisher"
npm publish "${packages[0]}" \
npm publish "$(realpath "${packages[0]}")" \
--ignore-scripts \
--tag bootstrap \
--provenance \
@@ -297,7 +298,7 @@ jobs:
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Verify the prerelease did not claim latest
- name: Verify npm-required bootstrap tags
run: |
set -euo pipefail
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
@@ -313,8 +314,13 @@ jobs:
const fs = require("node:fs");
const [path, expected] = process.argv.slice(2);
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
if (
tags.bootstrap !== expected ||
typeof tags.latest !== "string" ||
tags.latest.length === 0
) {
console.error("cmux-sdk dist-tag validation failed.");
process.exit(1);
}
NODE
@@ -336,5 +342,6 @@ jobs:
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--require-dist-tag latest \
--publisher owner \
--artifact "${packages[0]}"
+3 -3
View File
@@ -72,7 +72,7 @@ jobs:
fi
# The conventions lint (free-function ban, namespace-type rule, ...)
# covers every package, so it runs for any Packages/ change too.
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
echo "should_lint=true" >> "$GITHUB_OUTPUT"
else
echo "No package-owned files changed; skipping conventions lint."
@@ -99,8 +99,8 @@ jobs:
# namespace-enums) plus the repo-wide namespace-type rule (no
# all-static "namespace" types in any package). Exits non-zero on any
# unjustified ERROR; sanctioned exceptions carry a lint:allow /
# TRANSITIONAL / carve-out marker, and pre-existing namespace-type
# debt is grandfathered in scripts/lint-namespace-types-baseline.txt.
# TRANSITIONAL / carve-out marker, and pre-existing debt is
# grandfathered in the lint baseline files under scripts/.
./scripts/lint-ios-package-conventions.sh
mobile-core-package:
+14
View File
@@ -188,6 +188,20 @@ extension CMUXCLI {
}
static func feedHookCommandString(for def: AgentHookDef, agentEvent: String) -> String {
if def.name == "codex",
let injectedEvent = CodexHookInjectionSchema.current.events.first(where: {
$0.agentEvent == agentEvent
}) {
let inline = codexFireAndForgetAgentHookShellCommand(
"cmux hooks codex \(injectedEvent.cmuxSubcommand)",
for: def
)
return codexPersistentHookScriptCommand(
inline,
eventTag: "feed-\(agentEvent)"
)
}
let inline: String
let noOpCommand = feedHookNoOpShellCommand(for: def, agentEvent: agentEvent)
switch def.format {
+107 -4
View File
@@ -4,17 +4,22 @@ import Foundation
extension CMUXCLI {
/// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must
/// splice ahead of the user's args to enable + inject cmux's fire-and-forget
/// hooks for one codex invocation. Returns the arg list:
/// hooks for one codex invocation when no persistent cmux channel is
/// installed. Returns the arg list:
/// --enable\0hooks\0--dangerously-bypass-hook-trust\0
/// -c\0hooks.SessionStart=[{hooks=[{type="command",command='''<ff>''',timeout=10000}]}]\0
/// -c\0hooks.UserPromptSubmit=...\0 ... (one `-c` pair per event)
/// where `<ff>` is `codexFireAndForgetAgentHookShellCommand(...)` so each
/// hook returns `{}` to codex instantly and backgrounds the real cmux call.
/// Requires no live socket: pure string construction from the agent def.
/// Before emission, an existing cmux-owned persistent hook channel is
/// reconciled in place and supersedes wrapper injection for this launch.
/// No live socket is required.
func emitCodexWrapperInjectArgs() throws {
guard let codexDef = Self.agentDef(named: "codex") else {
throw CLIError(message: "Codex hook integration is unavailable.")
}
let usesPersistentChannel = reconcileCodexPersistentHooksForWrapper()
let eventsToInject = usesPersistentChannel ? [] : CodexHookInjectionSchema.current.events
// Prefer a #!/bin/sh SCRIPT FILE as the hook command over an inline shell
// snippet. Some codex-compatible runtimes (subrouters, proxies) exec the
// `command` string directly as a program instead of via a shell, so an
@@ -26,8 +31,15 @@ extension CMUXCLI {
// hooks), not the user's ~/.codex. Any write failure falls back to the
// inline snippet so the working path can never regress.
let hooksDir = Self.codexHookScriptsDirectory()
defer {
Self.garbageCollectCodexHookScripts(
retaining: Self.currentCodexWrapperHookScriptFilenames(for: codexDef)
.union(Self.installedCodexHookScriptFilenames(for: codexDef))
)
}
guard !eventsToInject.isEmpty else { return }
var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"]
for event in CodexHookInjectionSchema.current.events {
for event in eventsToInject {
let ff = Self.codexFireAndForgetAgentHookShellCommand(
"cmux hooks codex \(event.cmuxSubcommand)", for: codexDef
)
@@ -114,9 +126,100 @@ extension CMUXCLI {
}
}
/// Names that the current wrapper schema may reference from a live session.
static func currentCodexWrapperHookScriptFilenames(for def: AgentHookDef) -> Set<String> {
Set(CodexHookInjectionSchema.current.events.compactMap { event in
let body = codexFireAndForgetAgentHookShellCommand(
"cmux hooks codex \(event.cmuxSubcommand)",
for: def
)
return CodexHookScriptName(
contents: "#!/bin/sh\n\(body)\n",
subcommand: event.cmuxSubcommand
)?.filename
})
}
/// Cmux-generated script names referenced by the active persistent config.
static func installedCodexHookScriptFilenames(for def: AgentHookDef) -> Set<String> {
let fileURL = URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true)
.appendingPathComponent(def.configFile, isDirectory: false)
guard let data = try? Data(contentsOf: fileURL),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let hooks = root["hooks"] as? [String: Any],
let hooksDirectory = codexHookScriptsDirectory()?.standardizedFileURL else {
return []
}
var filenames = Set<String>()
for value in hooks.values {
guard let groups = value as? [[String: Any]] else { continue }
for group in groups {
guard let handlers = group["hooks"] as? [[String: Any]] else { continue }
for handler in handlers {
guard let command = handler["command"] as? String else { continue }
let url = URL(fileURLWithPath: command, isDirectory: false)
guard url.deletingLastPathComponent().standardizedFileURL == hooksDirectory,
CodexHookScriptName(filename: url.lastPathComponent) != nil else {
continue
}
filenames.insert(url.lastPathComponent)
}
}
}
return filenames
}
/// Removes obsolete regular files only when their names prove cmux ownership.
/// Live Codex sessions may still hold paths from another tagged build, and
/// concurrent launches can briefly overlap script generation, so collection
/// waits until no Codex process is running and leaves recent files alone.
static func garbageCollectCodexHookScripts(retaining filenames: Set<String>) {
guard !hasRunningCodexProcess(),
let directory = codexHookScriptsDirectory(),
let contents = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
options: [.skipsHiddenFiles]
) else {
return
}
let newestRemovableDate = Date().addingTimeInterval(-60)
for url in contents where !filenames.contains(url.lastPathComponent) {
let values = try? url.resourceValues(forKeys: [
.contentModificationDateKey,
.isRegularFileKey,
])
guard CodexHookScriptName(filename: url.lastPathComponent) != nil,
values?.isRegularFile == true,
let modificationDate = values?.contentModificationDate,
modificationDate < newestRemovableDate else {
continue
}
try? FileManager.default.removeItem(at: url)
}
}
/// Conservatively detects sessions that may still reference an older hook generation.
private static func hasRunningCodexProcess() -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
process.arguments = ["-x", "codex"]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
return process.terminationStatus == 0
} catch {
return true
}
}
static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String {
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\""
let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( timer=; trap \"kill \\$timer 2>/dev/null || true; wait \\$timer 2>/dev/null || true; exit 0\" HUP INT TERM; sleep 30 & timer=\"$!\"; wait \"$timer\" 2>/dev/null || true; timer=; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; wait \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\""
let noOp = stdinDrainingHookNoOpShellCommand
return [
"cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"",
+1
View File
@@ -81,6 +81,7 @@ extension CMUXCLI {
"codex",
"codex-hook",
"codex-teams",
"comments",
"config",
"copy-mode",
"current-window",
+181
View File
@@ -0,0 +1,181 @@
import Foundation
/// `cmux comments` read-only access to diff-viewer review comments.
///
/// Strings resolve through `CMUXDiffViewerLocalization`, which reads the enclosing
/// app bundle: the CLI executable carries no string catalog of its own, so
/// `String(localized:)` here would always fall back to its default value.
extension CMUXCLI {
static let commentsUsage = CMUXDiffViewerLocalization.string(
"cli.comments.usage",
defaultValue: """
Usage: cmux comments <subcommand> [options]
Review comments saved from the diff viewer, stored per git repository.
Subcommands:
list [--repo <path>] [--all] [--json]
List review comments for a repository (default: the git repository
containing the current directory). Lists pending comments only;
--all includes comments already delivered to an agent through a
TextBox submission.
"""
)
/// Runs `cmux comments <subcommand>`; `list` is the only subcommand today.
/// Rejects anything unrecognized before it resolves a repository or calls the socket.
func runCommentsNamespace(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat
) throws {
if hasHelpRequest(beforeSeparator: commandArgs) {
print(Self.commentsUsage)
return
}
guard let sub = commandArgs.first?.lowercased() else {
throw CLIError(message: CMUXDiffViewerLocalization.string(
"cli.comments.error.subcommandRequired",
defaultValue: "comments requires a subcommand. Try: list"
))
}
let rest = Array(commandArgs.dropFirst())
switch sub {
case "list", "ls":
let (repoOption, remainder) = parseOption(rest, name: "--repo")
// `parseOption` takes the next token verbatim, so `--repo --all`
// would resolve a repository named "--all". A path that starts with
// a dash can still be passed as `./-name`.
if let repoOption, repoOption.hasPrefix("--") {
throw CLIError(message: CMUXDiffViewerLocalization.string(
"cli.comments.error.repoRequiresPath",
defaultValue: "--repo requires a path. For a path starting with a dash, pass it as ./-name"
))
}
// Fail closed on anything unrecognized: neither a typo like `--al`
// nor a stray positional may read as a supported request.
if let unexpected = remainder.first(where: { $0 != "--all" }) {
throw CLIError(message: String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.error.unexpectedArgument",
defaultValue: "Unexpected argument '%@' for cmux comments list. Supported: --repo <path>, --all, --json"
),
unexpected
))
}
let includeConsumed = remainder.contains("--all")
let startPath = repoOption ?? FileManager.default.currentDirectoryPath
var params: [String: Any] = ["repo_root": try commentsGitRepoRoot(startingAt: startPath)]
if includeConsumed {
params["include_consumed"] = true
}
let payload = try client.sendV2(method: "comments.list", params: params)
printCommentsListPayload(payload, jsonOutput: jsonOutput, idFormat: idFormat)
default:
throw CLIError(message: String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.error.unknownSubcommand",
defaultValue: "Unknown comments subcommand '%@'. Try: list"
),
sub
))
}
}
/// Resolves the git top level for `--repo` (or the current directory), so the
/// socket receives the same canonical root the store is keyed by.
private func commentsGitRepoRoot(startingAt directory: String) throws -> String {
let result = CLIProcessRunner.runProcess(
executablePath: "/usr/bin/env",
arguments: ["git", "-C", directory, "rev-parse", "--show-toplevel"],
timeout: 10
)
let root = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard !result.timedOut, result.status == 0, !root.isEmpty else {
throw CLIError(message: String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.error.notARepository",
defaultValue: "cmux comments requires a git repository: %@"
),
directory
))
}
return root
}
/// Builds the count line.
///
/// Selection stays here rather than in catalog plural variations: the count is
/// resolved before the string is, so a `variations.plural` entry could not see
/// it. The catalog's non-singular values therefore avoid numeral-governed
/// nouns, keeping one form grammatical for every count above one in Slavic and
/// Arabic locales.
private func commentsListHeaderText(count: Int, repoRoot: String) -> String {
if count == 1 {
return String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.list.header.one",
defaultValue: "1 review comment (repo: %@)"
),
repoRoot
)
}
return String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.list.header.other",
defaultValue: "%1$lld review comments (repo: %2$@)"
),
Int64(count),
repoRoot
)
}
/// Renders a `comments.list` reply: raw JSON when `--json` is set, otherwise one
/// line per comment with its anchor text and message.
private func printCommentsListPayload(
_ payload: [String: Any],
jsonOutput: Bool,
idFormat: CLIIDFormat
) {
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
return
}
let comments = payload["comments"] as? [[String: Any]] ?? []
let repoRoot = payload["repo_root"] as? String ?? ""
guard !comments.isEmpty else {
print(String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.list.empty",
defaultValue: "No review comments. (repo: %@)"
),
repoRoot
))
return
}
print(commentsListHeaderText(count: comments.count, repoRoot: repoRoot))
for comment in comments {
let filePath = comment["filePath"] as? String ?? "?"
let startLine = intFromAny(comment["startLine"]) ?? 0
let endLine = intFromAny(comment["endLine"]) ?? startLine
let range = endLine > startLine ? "\(startLine)-\(endLine)" : "\(startLine)"
let state = comment["consumedAt"] == nil
? CMUXDiffViewerLocalization.string("cli.comments.list.statePending", defaultValue: "pending")
: CMUXDiffViewerLocalization.string("cli.comments.list.stateConsumed", defaultValue: "consumed")
print("- \(filePath):\(range) [\(state)]")
if let lineText = comment["lineText"] as? String, !lineText.isEmpty {
print(String.localizedStringWithFormat(
CMUXDiffViewerLocalization.string(
"cli.comments.list.anchor",
defaultValue: " anchor: %@"
),
lineText
))
}
if let message = comment["message"] as? String, !message.isEmpty {
print(" \(message)")
}
}
}
}
+54 -37
View File
@@ -3,6 +3,13 @@ import Darwin
import Foundation
extension CMUXCLI {
var restoreCommandUsageLine: String {
String(
localized: "cli.help.restore",
defaultValue: "restore [--surface <id|ref>] <kind> <checkpoint-id> | restore --surface [id|ref]"
)
}
func controlAgentLaunchCommandPayload(
_ command: AgentLaunchCommand
) -> [String: Any] {
@@ -193,21 +200,12 @@ extension CMUXCLI {
)
}
let resolution = AgentProcessBindingResolution.controllingTTY.rawValue
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: [
"pid": Int(ProcessInfo.processInfo.processIdentifier),
"pid_resolution": resolution,
]
let payload = try implicitCallerIdentifyResponse(
client: client,
processEnvironment: processEnvironment
)
guard payload["source"] as? String == "pid",
payload["pid_resolution"] as? String == resolution,
let workspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(workspaceID),
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceID) else {
guard let surfaceID = identifiedCallerSurfaceID(in: payload) else {
throw currentRestoreSurfaceUnknownError()
}
return surfaceID
@@ -331,41 +329,60 @@ extension CMUXCLI {
}
private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector {
if arguments.first == "--surface" {
if arguments.count == 1 {
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: nil,
checkpointID: nil
)
}
guard arguments.count == 2, !arguments[1].isEmpty else {
if arguments == ["--surface"] {
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: nil,
checkpointID: nil
)
}
let surfaceOptionCount = arguments.filter { argument in
argument == "--surface" || argument.hasPrefix("--surface=")
}.count
guard surfaceOptionCount <= 1 else {
throw CLIError(message: String(
localized: "cli.restore.usage.surface",
defaultValue: "Usage: cmux restore --surface [id|ref]"
))
}
let (surface, positionalArguments) = parseOption(arguments, name: "--surface")
if surfaceOptionCount == 1 {
guard let surface,
!surface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.surface",
defaultValue: "Usage: cmux restore --surface [id|ref]"
))
}
return RestoreSelector(
surface: arguments[1],
usesCurrentSurface: false,
kind: nil,
checkpointID: nil
)
if positionalArguments.isEmpty {
return RestoreSelector(
surface: surface,
usesCurrentSurface: false,
kind: nil,
checkpointID: nil
)
}
}
guard arguments.count == 2,
!arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
guard positionalArguments.count == 2,
!positionalArguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!positionalArguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.positional",
defaultValue: "Usage: cmux restore <kind> <checkpoint-id>"
defaultValue: """
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
cmux restore <kind> <checkpoint-id> --surface <id|ref>
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
"""
))
}
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: arguments[0],
checkpointID: arguments[1]
surface: surface,
usesCurrentSurface: surface == nil,
kind: positionalArguments[0],
checkpointID: positionalArguments[1]
)
}
+224 -49
View File
@@ -4552,45 +4552,51 @@ struct CMUXCLI {
params["window_id"] = targetWindow
}
let includeCaller = !hasFlag(commandArgs, name: "--no-caller")
if includeCaller {
let idWsFlag = optionValue(commandArgs, name: "--workspace")
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
if workspaceArg != nil || surfaceArg != nil {
let workspaceId = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
windowHandle: targetWindow,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceId {
caller["workspace_id"] = workspaceId
}
if surfaceArg != nil {
guard let surfaceId = try normalizeSurfaceHandle(
surfaceArg,
let idWsFlag = optionValue(commandArgs, name: "--workspace")
let idSurfaceFlag = optionValue(commandArgs, name: "--surface")
let usesImplicitCaller = includeCaller
&& effectiveWindowRaw == nil
&& idWsFlag == nil
&& idSurfaceFlag == nil
let response: [String: Any]
if usesImplicitCaller {
response = try implicitCallerIdentifyResponse(
client: client,
processEnvironment: processEnv
)
} else {
if includeCaller {
let workspaceArg = idWsFlag ?? (effectiveWindowRaw == nil ? processEnv["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = idSurfaceFlag ?? (idWsFlag == nil && effectiveWindowRaw == nil ? processEnv["CMUX_SURFACE_ID"] : nil)
if workspaceArg != nil || surfaceArg != nil {
let workspaceId = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
workspaceHandle: workspaceId,
windowHandle: targetWindow
) else {
throw CLIError(message: "Invalid surface handle")
windowHandle: targetWindow,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceId {
caller["workspace_id"] = workspaceId
}
if surfaceArg != nil {
guard let surfaceId = try normalizeSurfaceHandle(
surfaceArg,
client: client,
workspaceHandle: workspaceId,
windowHandle: targetWindow
) else {
throw CLIError(message: "Invalid surface handle")
}
caller["surface_id"] = surfaceId
}
if !caller.isEmpty {
params["caller"] = caller
}
caller["surface_id"] = surfaceId
}
if !caller.isEmpty {
params["caller"] = caller
}
}
if effectiveWindowRaw == nil,
idWsFlag == nil,
idSurfaceFlag == nil,
let callerTTY = resolveCallerDescriptorTTYName() {
params["caller_tty"] = callerTTY
}
response = try client.sendV2(method: "system.identify", params: params)
}
let response = try client.sendV2(method: "system.identify", params: params)
print(jsonString(formatIDs(response, mode: idFormat)))
case "list-windows":
@@ -4729,6 +4735,14 @@ struct CMUXCLI {
windowOverride: windowId
)
case "comments":
try runCommentsNamespace(
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat
)
case "layout": try runLayoutNamespace(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat, windowOverride: windowId)
case "list-workspaces":
@@ -15585,6 +15599,8 @@ struct CMUXCLI {
return Self.remotesUsage
case "todo":
return Self.todoUsage
case "comments":
return Self.commentsUsage
case "ai-accounts":
return Self.aiAccountsUsage
case "ping":
@@ -15825,11 +15841,13 @@ struct CMUXCLI {
"""
case "restore":
return String(localized: "cli.restore.help", defaultValue: """
Usage: cmux restore <kind> <checkpoint-id>
Usage: cmux restore [--surface <id|ref>] <kind> <checkpoint-id>
cmux restore <kind> <checkpoint-id> --surface <id|ref>
cmux restore --surface=<id|ref> <kind> <checkpoint-id>
cmux restore --surface [id|ref]
Replace this CLI process with the persisted surface process. New
records preserve argv, environment, and cwd as structured values;
records preserve launch arguments and cwd as structured values;
command-only records from older builds use a compatibility shell.
With no id or ref, --surface uses the calling cmux surface.
""")
@@ -26262,6 +26280,97 @@ struct CMUXCLI {
return resolveCallerDescriptorTTYName()
}
func implicitCallerIdentifyResponse(
client: SocketClient,
processEnvironment: [String: String]
) throws -> [String: Any] {
let callerTTY = resolveCallerDescriptorTTYName()
?? resolveCallerTTYName(includeAmbientTTY: false)
if let callerTTY {
let ttyResponse = try client.sendV2(
method: "system.identify",
params: ["caller_tty": callerTTY]
)
if identifiedCallerSurfaceID(in: ttyResponse) != nil
|| identifyResponseHasMalformedCallerSurface(ttyResponse) {
return ttyResponse
}
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
client: client,
processEnvironment: processEnvironment
) {
return try client.sendV2(
method: "system.identify",
params: environmentParams
)
}
return ttyResponse
}
if let environmentParams = try implicitCallerEnvironmentIdentifyParams(
client: client,
processEnvironment: processEnvironment
) {
return try client.sendV2(
method: "system.identify",
params: environmentParams
)
}
return try client.sendV2(method: "system.identify")
}
func identifiedCallerSurfaceID(in response: [String: Any]) -> String? {
guard let caller = response["caller"] as? [String: Any],
let surfaceID = normalizedHandleValue(caller["surface_id"] as? String),
isUUID(surfaceID) else {
return nil
}
return surfaceID
}
private func implicitCallerEnvironmentIdentifyParams(
client: SocketClient,
processEnvironment: [String: String]
) throws -> [String: Any]? {
let workspaceArg = normalizedHandleValue(processEnvironment["CMUX_WORKSPACE_ID"])
let surfaceArg = normalizedHandleValue(processEnvironment["CMUX_SURFACE_ID"])
guard workspaceArg != nil || surfaceArg != nil else { return nil }
let workspaceID = try normalizeWorkspaceHandle(
workspaceArg,
client: client,
allowCurrent: surfaceArg != nil
)
var caller: [String: Any] = [:]
if let workspaceID {
caller["workspace_id"] = workspaceID
}
if let surfaceArg {
guard let surfaceID = try normalizeSurfaceHandle(
surfaceArg,
client: client,
workspaceHandle: workspaceID,
windowHandle: nil
) else {
return nil
}
caller["surface_id"] = surfaceID
}
guard !caller.isEmpty else { return nil }
return ["caller": caller]
}
private func identifyResponseHasMalformedCallerSurface(
_ response: [String: Any]
) -> Bool {
guard let caller = response["caller"] as? [String: Any],
let surface = caller["surface_id"],
!(surface is NSNull) else {
return false
}
return identifiedCallerSurfaceID(in: response) == nil
}
func resolveCallerDescriptorTTYName() -> String? {
for fileDescriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
if let rawTTYName = ttyname(fileDescriptor),
@@ -29559,7 +29668,10 @@ export default CMUXSessionRestore;
return false
}
private func installAgentHooks(_ def: AgentHookDef) throws {
private func installAgentHooks(
_ def: AgentHookDef,
automaticReconciliation: Bool = false
) throws {
if def.name == "opencode" { try installOpenCodePluginHooks(def); return }
if def.name == "pi" { try installPiExtensionHooks(def); return }
if def.name == "omp" { try installOmpExtensionHooks(def); return }
@@ -29588,7 +29700,8 @@ export default CMUXSessionRestore;
let fm = FileManager.default
let configDir = def.resolvedConfigDir()
let filePath = "\(configDir)/\(def.configFile)"
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
let skipConfirm = automaticReconciliation
|| ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let configDirectoryFileError = String.localizedStringWithFormat(
@@ -29604,7 +29717,9 @@ export default CMUXSessionRestore;
if def.createConfigDirIfMissing {
throw CLIError(message: configDirectoryFileError)
}
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
if !automaticReconciliation {
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
}
return
}
if !configPathExists {
@@ -29615,7 +29730,9 @@ export default CMUXSessionRestore;
throw CLIError(message: configDirectoryFileError)
}
} else {
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
if !automaticReconciliation {
print("Required agent configuration is missing. Run `cmux hooks setup` after installing your agent CLI.")
}
return
}
}
@@ -29628,6 +29745,12 @@ export default CMUXSessionRestore;
existing = json
}
let existingHooksValue: Any = existing["hooks"] ?? [String: Any]()
if automaticReconciliation,
!Self.jsonHookValueContainsCmuxOwnedCommand(existingHooksValue, for: def) {
return
}
var hooks = existing["hooks"] as? [String: Any] ?? [:]
let newHooks = buildHooksDict(for: def)
@@ -29771,7 +29894,9 @@ export default CMUXSessionRestore;
if oldString == newString {
// No-op install; skip the write and the prompt entirely.
print("\(def.displayName) hooks already up to date at \(filePath)")
if !automaticReconciliation {
print("\(def.displayName) hooks already up to date at \(filePath)")
}
} else {
if !skipConfirm {
Self.printInstallPreview(
@@ -29787,10 +29912,12 @@ export default CMUXSessionRestore;
}
}
try newData.write(to: URL(fileURLWithPath: filePath), options: .atomic)
print("\(def.displayName) hooks installed at \(filePath)")
if !automaticReconciliation {
print("\(def.displayName) hooks installed at \(filePath)")
}
}
if let note = def.postInstallNote {
if !automaticReconciliation, let note = def.postInstallNote {
print(note)
}
@@ -29836,14 +29963,40 @@ export default CMUXSessionRestore;
}
}
try newContent.write(toFile: configPath, atomically: true, encoding: .utf8)
if def.name == "codex", !codexHookTrustEntries.isEmpty, trustInstall.installedTrust {
print("Enabled hooks and approved cmux hooks in \(configPath)")
} else {
print("Enabled hooks in \(configPath)")
if !automaticReconciliation {
if def.name == "codex", !codexHookTrustEntries.isEmpty, trustInstall.installedTrust {
print("Enabled hooks and approved cmux hooks in \(configPath)")
} else {
print("Enabled hooks in \(configPath)")
}
}
}
}
}
if def.name == "codex", !automaticReconciliation {
Self.garbageCollectCodexHookScripts(
retaining: Self.currentCodexWrapperHookScriptFilenames(for: def)
.union(Self.installedCodexHookScriptFilenames(for: def))
)
}
}
/// Repairs an opted-in persistent Codex channel before wrapper launch.
func reconcileCodexPersistentHooksForWrapper() -> Bool {
guard let def = Self.agentDef(named: "codex") else { return false }
try? installAgentHooks(def, automaticReconciliation: true)
let fileURL = URL(fileURLWithPath: def.resolvedConfigDir(), isDirectory: true)
.appendingPathComponent(def.configFile, isDirectory: false)
guard let data = try? Data(contentsOf: fileURL),
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let hooks = root["hooks"] as? [String: Any] else {
return false
}
return hooks.values.contains {
Self.jsonHookValueContainsCmuxOwnedCommand($0, for: def)
}
}
private func pruneLegacyGrokHookFileIfNeeded(
@@ -32590,7 +32743,28 @@ export default CMUXSessionRestore;
surfaceId: String? = nil,
socketPassword: String? = nil
) {
let hookEventName = Self.feedEventName(forClaudeSubcommand: subcommand)
let fallbackHookEventName = Self.feedEventName(forClaudeSubcommand: subcommand)
let reportedHookEventName = parsedInput.object.flatMap {
firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"])
} ?? parsedInput.rawObject.flatMap {
firstString(in: $0, keys: ["hook_event_name", "hookEventName", "event", "event_name"])
}
let hookEventName: String
if source == "codex",
let reportedHookEventName,
reportedHookEventName.replacingOccurrences(of: "_", with: "").lowercased()
== "permissionrequest" {
// A single notification handler now owns Codex PermissionRequest.
// Preserve the existing non-blocking Feed classification while that
// same handler drives the needs-input lifecycle and alert.
hookEventName = FeedEventClassifier.classify(
source: source,
event: reportedHookEventName,
toolName: ""
).0
} else {
hookEventName = fallbackHookEventName
}
guard !hookEventName.isEmpty else { return }
let promptText = hookEventName == "UserPromptSubmit"
? (feedPromptText(from: parsedInput.object) ?? parsedInput.rawFallback)
@@ -36061,7 +36235,7 @@ export default CMUXSessionRestore;
shortcuts
disable-browser | enable-browser | browser-status
agent-hibernation <on|off>
restore <kind> <checkpoint-id> | restore --surface [id|ref]
\(restoreCommandUsageLine)
restore-session
open <path-or-url>... [--workspace <id|ref|index>] [--surface <id|ref|index>] [--pane <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] [--no-focus]
diff [patch-file|-] [--source <unstaged|staged|branch|last-turn>] [--unstaged|--staged|--branch|--last-turn] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--cwd <path>] [--base <ref>] [--focus <true|false>] [--no-focus] [--title <text>] [--layout <split|unified>] [--font-size <points>]
@@ -36101,6 +36275,7 @@ export default CMUXSessionRestore;
workspace-action --action <name> [--workspace <id|ref|index>] [--window <id|ref|index>] [--title <text>] [--color <name|#hex>] [--description <text>]
workspace status [set <lane|auto>] [--workspace <id|ref|index>] [--window <id|ref|index>]
todo <add|list|check|uncheck|start|rm|clear> [args] [--workspace <id|ref|index>] [--window <id|ref|index>]
comments list [--repo <path>] [--all] [--json]
move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--title <text>] [--focus <true|false>]
list-workspaces [--window <id|ref|index>]
new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] [--group <id|ref>] [--group-placement afterCurrent|top|end] [--group-reference <workspace>]
@@ -193,6 +193,26 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
/// `b` is ``DiagnosticPathKind`` for the affected path, and `c` is the
/// matching positive, process-local session correlation ID.
case transportPathEvent = 55
/// A phone-driven browser stream session changed lifecycle state on the
/// Mac. `a` is the stage (1 started, 2 replaced an existing session,
/// 3 stopped, 4 first frame emitted), and `c` is the positive browser
/// panel correlation ID derived from the panel UUID.
case browserStreamLifecycle = 56
/// Replayed phone input reached a streamed browser panel. `a` is the
/// input kind (1 pointer, 2 key, 3 text, 4 suppressed no-editable
/// backspace), `b` is the click count for pointers, 1 for keys, or the
/// inserted character count for text, and `c` is the panel correlation ID.
case browserInputReplayed = 57
/// The streamed page's editable-focus state changed or a replayed click's
/// focus assist resolved. `a` is 1 when an editable has focus (else 0),
/// `b` is the focus-assist outcome (0 no editable at the point, 1 focus
/// moved, 2 already focused, 3 beacon-reported transition), and `c` is
/// the panel correlation ID.
case browserEditableFocus = 58
/// A phone-initiated `mobile.browser.create` request resolved on the Mac.
/// `a` is 1 on success else 0, and `c` is the panel correlation ID of the
/// created panel (absent on failure).
case browserPanelCreateResolved = 59
}
/// Scene phase carried by ``DiagnosticEventCode/appLifecycleChanged``.
@@ -353,6 +353,14 @@ public struct DiagnosticEventPresentation: Sendable {
localized("diagnostics.event.transportCloseAttribution", defaultValue: "Transport close attributed")
case .transportPathEvent:
localized("diagnostics.event.transportPathEvent", defaultValue: "Transport path changed")
case .browserStreamLifecycle:
localized("diagnostics.event.browserStreamLifecycle", defaultValue: "Browser stream lifecycle")
case .browserInputReplayed:
localized("diagnostics.event.browserInputReplayed", defaultValue: "Browser input replayed")
case .browserEditableFocus:
localized("diagnostics.event.browserEditableFocus", defaultValue: "Browser editable focus")
case .browserPanelCreateResolved:
localized("diagnostics.event.browserPanelCreateResolved", defaultValue: "Browser panel create resolved")
}
}
@@ -387,6 +395,14 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "composer_active", value: booleanName(raw))
case .composerKeyboardToggleWhilePresented:
return Field(key: "terminal_input_focused", value: booleanName(raw))
case .browserStreamLifecycle:
return Field(key: "stage", value: browserStreamStageName(raw))
case .browserInputReplayed:
return Field(key: "input", value: browserInputKindName(raw))
case .browserEditableFocus:
return Field(key: "editable_focused", value: booleanName(raw))
case .browserPanelCreateResolved:
return Field(key: "created", value: booleanName(raw))
default:
return Field(key: "detail_1", value: String(raw))
}
@@ -411,6 +427,10 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "draft_empty", value: booleanName(raw))
case .composerActiveTransition, .composerKeyboardToggleWhilePresented:
return Field(key: "first_responder", value: responderName(raw))
case .browserInputReplayed:
return Field(key: "count", value: String(raw))
case .browserEditableFocus:
return Field(key: "outcome", value: browserFocusOutcomeName(raw))
default:
return Field(key: "detail_2", value: String(raw))
}
@@ -445,6 +465,9 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "session", value: String(raw))
case .composerActiveTransition:
return Field(key: "terminal_input_focused", value: booleanName(raw))
case .browserStreamLifecycle, .browserInputReplayed,
.browserEditableFocus, .browserPanelCreateResolved:
return Field(key: "panel", value: String(raw))
default:
return Field(key: "detail_3", value: String(raw))
}
@@ -598,6 +621,48 @@ public struct DiagnosticEventPresentation: Sendable {
}
}
private func browserStreamStageName(_ raw: Int) -> String {
switch raw {
case 1: localized("diagnostics.browserStage.started", defaultValue: "Started")
case 2: localized("diagnostics.browserStage.replaced", defaultValue: "Replaced existing session")
case 3: localized("diagnostics.browserStage.stopped", defaultValue: "Stopped")
case 4: localized("diagnostics.browserStage.firstFrame", defaultValue: "First frame delivered")
default:
localized(
"diagnostics.unknown.browserStage",
defaultValue: "Unknown stage (\(raw))"
)
}
}
private func browserInputKindName(_ raw: Int) -> String {
switch raw {
case 1: localized("diagnostics.browserInput.pointer", defaultValue: "Pointer")
case 2: localized("diagnostics.browserInput.key", defaultValue: "Key")
case 3: localized("diagnostics.browserInput.text", defaultValue: "Text")
case 4: localized("diagnostics.browserInput.keySuppressed", defaultValue: "Key suppressed")
default:
localized(
"diagnostics.unknown.browserInput",
defaultValue: "Unknown input (\(raw))"
)
}
}
private func browserFocusOutcomeName(_ raw: Int) -> String {
switch raw {
case 0: localized("diagnostics.browserFocus.none", defaultValue: "No editable at point")
case 1: localized("diagnostics.browserFocus.moved", defaultValue: "Focus moved")
case 2: localized("diagnostics.browserFocus.already", defaultValue: "Already focused")
case 3: localized("diagnostics.browserFocus.beacon", defaultValue: "Beacon transition")
default:
localized(
"diagnostics.unknown.browserFocus",
defaultValue: "Unknown outcome (\(raw))"
)
}
}
private func pathEventName(_ raw: Int) -> String {
switch raw {
case 1: localized("diagnostics.pathOperation.opened", defaultValue: "Opened")
@@ -690,6 +755,13 @@ public struct DiagnosticEventPresentation: Sendable {
case "remote_sequence": localized("diagnostics.field.remoteSequence", defaultValue: "Remote sequence")
case "delivered_sequence": localized("diagnostics.field.deliveredSequence", defaultValue: "Delivered sequence")
case "next_sequence": localized("diagnostics.field.nextSequence", defaultValue: "Next sequence")
case "stage": localized("diagnostics.field.stage", defaultValue: "Stage")
case "input": localized("diagnostics.field.input", defaultValue: "Input")
case "count": localized("diagnostics.field.count", defaultValue: "Count")
case "outcome": localized("diagnostics.field.outcome", defaultValue: "Outcome")
case "editable_focused": localized("diagnostics.field.editableFocused", defaultValue: "Editable focused")
case "created": localized("diagnostics.field.created", defaultValue: "Created")
case "panel": localized("diagnostics.field.panel", defaultValue: "Panel")
case "detail_1": localized("diagnostics.field.detail1", defaultValue: "Detail 1")
case "detail_2": localized("diagnostics.field.detail2", defaultValue: "Detail 2")
case "detail_3": localized("diagnostics.field.detail3", defaultValue: "Detail 3")
@@ -8,4 +8,6 @@ public struct MobileBrowserStreamCapability: Sendable {
public static let viewportIdentifier = "browser.stream.viewport.v1"
/// Version-one native browser dialog mirroring capability identifier.
public static let dialogIdentifier = "browser.stream.dialog.v1"
/// Version-one phone-initiated browser panel creation capability identifier.
public static let createIdentifier = "browser.stream.create.v1"
}
@@ -159,6 +159,10 @@ import Testing
.reachabilityChanged: "Network reachability changed",
.transportCloseAttribution: "Transport close attributed",
.transportPathEvent: "Transport path changed",
.browserStreamLifecycle: "Browser stream lifecycle",
.browserInputReplayed: "Browser input replayed",
.browserEditableFocus: "Browser editable focus",
.browserPanelCreateResolved: "Browser panel create resolved",
]
#expect(Set(expected.keys) == Set(DiagnosticEventCode.allCases))
@@ -235,6 +239,54 @@ import Testing
.init(key: "remote_sequence", value: "20"),
])
let browserLifecycle = englishPresentation.describe(DiagnosticEvent(
code: .browserStreamLifecycle,
tNanos: 1,
a: 4,
c: 987
))
#expect(browserLifecycle.fields == [
.init(key: "stage", value: "First frame delivered"),
.init(key: "panel", value: "987"),
])
let browserInput = englishPresentation.describe(DiagnosticEvent(
code: .browserInputReplayed,
tNanos: 1,
a: 4,
b: 1,
c: 987
))
#expect(browserInput.fields == [
.init(key: "input", value: "Key suppressed"),
.init(key: "count", value: "1"),
.init(key: "panel", value: "987"),
])
let browserFocus = englishPresentation.describe(DiagnosticEvent(
code: .browserEditableFocus,
tNanos: 1,
a: 1,
b: 2,
c: 987
))
#expect(browserFocus.fields == [
.init(key: "editable_focused", value: "Yes"),
.init(key: "outcome", value: "Already focused"),
.init(key: "panel", value: "987"),
])
let browserCreate = englishPresentation.describe(DiagnosticEvent(
code: .browserPanelCreateResolved,
tNanos: 1,
a: 1,
c: 987
))
#expect(browserCreate.fields == [
.init(key: "created", value: "Yes"),
.init(key: "panel", value: "987"),
])
for described in [recovery, endpoint, session, composer, input] {
#expect(!described.fields.contains { ["a", "b", "c", "ms"].contains($0.key) })
}
@@ -155,6 +155,7 @@ extension AuthCoordinator {
sessionCache.setHasTokens(true)
currentUser = fixtureUser
isAuthenticated = true
publishAuthenticatedSessionIdentity()
return
}
@@ -429,3 +429,87 @@ public struct AuthenticatedSessionSnapshot: Sendable, Equatable,
public var debugDescription: String { description }
}
/// Credential-free identity for synchronously binding queued work to the
/// current authenticated session.
public struct AuthenticatedSessionIdentity: Sendable, Equatable,
CustomStringConvertible, CustomDebugStringConvertible {
public let generation: UInt64
public let accountID: String
public init(generation: UInt64, accountID: String) {
self.generation = generation
self.accountID = accountID
}
public var description: String {
"AuthenticatedSessionIdentity(generation: \(generation), accountID: <redacted>)"
}
public var debugDescription: String { description }
}
public extension AuthCoordinator {
/// The current account plus session generation without either credential.
var authenticatedSessionIdentity: AuthenticatedSessionIdentity? {
guard isAuthenticated,
!sessionTokenTransitionIsActive,
let accountID = currentUser?.id,
!accountID.isEmpty else { return nil }
return AuthenticatedSessionIdentity(
generation: authSessionGeneration,
accountID: accountID
)
}
/// A credential-free lifecycle stream for consumers that must cancel work
/// at the exact auth transition instead of discovering stale authority on
/// their next request. The first element is always the current state.
func authenticatedSessionIdentities()
-> AsyncStream<AuthenticatedSessionIdentity?> {
let continuationID = UUID()
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) {
continuation in
authenticatedSessionIdentityContinuations[continuationID] =
continuation
continuation.yield(publishedAuthenticatedSessionIdentity)
continuation.onTermination = { @Sendable [weak self] _ in
Task { @MainActor [weak self] in
self?.authenticatedSessionIdentityContinuations[
continuationID
] = nil
}
}
}
}
/// Whether a credential-free identity still names the published session.
/// This stays stable through same-account revalidation but flips false at
/// the synchronous start of sign-out.
func isAuthenticatedSessionIdentityCurrent(
_ identity: AuthenticatedSessionIdentity
) -> Bool {
publishedAuthenticatedSessionIdentity == identity
}
}
extension AuthCoordinator {
private var publishedAuthenticatedSessionIdentity:
AuthenticatedSessionIdentity? {
guard isAuthenticated,
!isCapturingSignOutCredentials,
let accountID = currentUser?.id,
!accountID.isEmpty else { return nil }
return AuthenticatedSessionIdentity(
generation: authSessionGeneration,
accountID: accountID
)
}
func publishAuthenticatedSessionIdentity() {
let identity = publishedAuthenticatedSessionIdentity
for continuation in authenticatedSessionIdentityContinuations.values {
continuation.yield(identity)
}
}
}
@@ -106,6 +106,9 @@ public final class AuthCoordinator {
@ObservationIgnored var signOutEpoch: UInt64 = 0
/// Monotonic sign-in attempt count, allocating each flow's attempt id.
@ObservationIgnored var signInAttemptCounter: UInt64 = 0
@ObservationIgnored var authenticatedSessionIdentityContinuations: [
UUID: AsyncStream<AuthenticatedSessionIdentity?>.Continuation
] = [:]
/// Sign-in attempts that currently own a possible write to the token store.
///
/// This ownership spans the whole flow, not just the credential-exchange
@@ -155,6 +158,7 @@ public final class AuthCoordinator {
private func finishSignInFlow(_ flow: SignInFlowContext) {
activeSignInFlows[flow.attempt] = nil
publishAuthenticatedSessionIdentity()
}
/// Creates an auth coordinator.
@@ -505,6 +509,7 @@ public final class AuthCoordinator {
// the local clear below).
advanceSessionGeneration()
signOutEpoch &+= 1
publishAuthenticatedSessionIdentity()
await phaseTimeoutRegistry.clear([.sendCode, .verifyCode, .passwordSignIn, .oauth, .validateSession])
// Capture the teardown credentials with raw stored reads (no refresh,
@@ -626,6 +631,7 @@ public final class AuthCoordinator {
currentUser = user
isAuthenticated = true
isRestoringSession = false
publishAuthenticatedSessionIdentity()
saveCachedUser(user)
sessionCache.setHasTokens(true)
await refreshTeams(generation: generation)
@@ -726,6 +732,7 @@ public final class AuthCoordinator {
currentUser = cachedUser
isAuthenticated = cachedUser != nil
isRestoringSession = false
publishAuthenticatedSessionIdentity()
}
func clearPersistedAuthForUITest() async {
@@ -757,6 +764,7 @@ public final class AuthCoordinator {
currentUser = state.currentUser
isAuthenticated = state.isAuthenticated
isRestoringSession = state.isRestoringSession
publishAuthenticatedSessionIdentity()
}
func loadCachedUser() -> CMUXAuthUser? {
@@ -8,6 +8,13 @@ import Foundation
/// that talk to the web API (e.g. ``PushRegistrationService``) so they never
/// reach for an auth singleton.
public protocol TokenProviding: Sendable {
/// Coherent account id + token pair pinned to one auth-session generation.
func authenticatedSessionSnapshot() async throws
-> AuthenticatedSessionSnapshot
/// Whether a previously captured snapshot still names the live session.
func isAuthenticatedSessionCurrent(
_ snapshot: AuthenticatedSessionSnapshot
) async -> Bool
/// The current access token, throwing when there is no valid session.
func accessToken() async throws -> String
/// The currently stored access token, without refresh or auth-state mutation.
@@ -28,4 +35,13 @@ public protocol TokenProviding: Sendable {
func forceRefreshAccessToken() async throws -> String
}
extension AuthCoordinator: TokenProviding {}
extension AuthCoordinator: TokenProviding {
public func isAuthenticatedSessionCurrent(
_ snapshot: AuthenticatedSessionSnapshot
) async -> Bool {
isAuthenticated
&& !sessionTokenTransitionIsActive
&& authSessionGeneration == snapshot.generation
&& currentUser?.id == snapshot.accountID
}
}
@@ -11,6 +11,12 @@ public protocol PushRegistering: Sendable {
/// Whether the user has opted into phone notifications.
var isEnabled: Bool { get async }
/// The furthest locally and remotely confirmed registration stage.
var snapshot: PushRegistrationSnapshot { get async }
/// A stream that immediately yields the current snapshot and every change.
func snapshots() async -> AsyncStream<PushRegistrationSnapshot>
/// Persist the opt-in flag, re-uploading any cached token on enable and
/// removing it server-side on disable.
func setEnabled(_ enabled: Bool) async
@@ -18,6 +24,10 @@ public protocol PushRegistering: Sendable {
/// Cache and (when opted in) upload a freshly registered APNs device token.
func register(deviceToken: Data) async
/// Records a terminal APNs token-registration callback failure without
/// retaining or exposing the system error description.
func deviceTokenRegistrationFailed() async
/// Re-upload the cached token (e.g. after sign-in). No-op unless opted in.
func syncTokenIfPossible() async
@@ -32,4 +42,11 @@ public protocol PushRegistering: Sendable {
/// live provider could authenticate as a NEXT account whose sign-in raced
/// the bounded teardown.
func unregisterFromServer(accessToken: String?, refreshToken: String?) async
/// Sign-out variant carrying the account id captured before local clear.
func unregisterFromServer(
accountID: String?,
accessToken: String?,
refreshToken: String?
) async
}
@@ -11,8 +11,10 @@ private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push")
/// URL, bundle id, `UserDefaults(suiteName:)`, and `URLSession`, then inject it
/// as `any PushRegistering`.
///
/// Privacy: notifications are **off by default**. Nothing (not even a device
/// token) is uploaded until the user enables them via ``setEnabled(_:)``.
/// Privacy: nothing (not even a device token) is uploaded until the app's
/// workspace-list permission flow is accepted or the user explicitly enables
/// notifications and the coordinator calls ``setEnabled(_:)``. An explicit
/// app opt-out remains persisted and authoritative.
public actor PushRegistrationService: PushRegistering {
private let tokenProvider: any TokenProviding
private let apiBaseURL: String
@@ -20,9 +22,24 @@ public actor PushRegistrationService: PushRegistering {
private let apnsEnvironment: String
private let defaults: UserDefaults
private let session: URLSession
private let retryDelays: [Duration]
private let retryJitter: @Sendable (ClosedRange<Double>) -> Double
private let retrySleep: @Sendable (Duration) async throws -> Void
private var retryTask: Task<Void, Never>?
private var unregisterDrainTask: Task<Void, Never>?
private var operationGeneration = UUID()
private var snapshotValue: PushRegistrationSnapshot
private var snapshotContinuations:
[UUID: AsyncStream<PushRegistrationSnapshot>.Continuation] = [:]
private static let enabledKey = "cmux.notifications.pushEnabled"
private static let cachedTokenKey = "cmux.notifications.deviceTokenHex"
private static let registeredAccountIDKey = "cmux.notifications.registeredAccountID"
private static let pendingUnregisterTokenKey = "cmux.notifications.pendingUnregisterToken"
private static let pendingUnregisterAccountIDKey = "cmux.notifications.pendingUnregisterAccountID"
private static let pendingUnregisterQueueKey =
"cmux.notifications.pendingUnregisters.v2"
private static let pendingUnregisterAttemptBudget = 4
/// Creates a push registration service.
///
@@ -43,7 +60,19 @@ public actor PushRegistrationService: PushRegistering {
bundleID: String,
apnsEnvironment: String,
suiteName: String? = nil,
session: sending URLSession = .shared
session: sending URLSession = .shared,
retryDelays: [Duration] = [
.seconds(1),
.seconds(4),
.seconds(15),
.seconds(60),
],
retryJitter: @escaping @Sendable (ClosedRange<Double>) -> Double = {
Double.random(in: $0)
},
retrySleep: @escaping @Sendable (Duration) async throws -> Void = {
try await ContinuousClock().sleep(for: $0)
}
) {
self.tokenProvider = tokenProvider
self.apiBaseURL = apiBaseURL
@@ -54,35 +83,126 @@ public actor PushRegistrationService: PushRegistering {
} else {
self.defaults = .standard
}
Self.migrateLegacyPendingUnregisters(in: self.defaults)
self.session = session
self.retryDelays = retryDelays
self.retryJitter = retryJitter
self.retrySleep = retrySleep
let enabled = self.defaults.bool(forKey: Self.enabledKey)
let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false
self.snapshotValue = PushRegistrationSnapshot(
isEnabled: enabled,
hasDeviceToken: hasToken,
backendState: enabled
? (hasToken ? .registrationRequired : .awaitingDeviceToken)
: .awaitingDeviceToken
)
}
public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) }
public var snapshot: PushRegistrationSnapshot { snapshotValue }
public func snapshots() -> AsyncStream<PushRegistrationSnapshot> {
let id = UUID()
return AsyncStream { continuation in
snapshotContinuations[id] = continuation
continuation.yield(snapshotValue)
continuation.onTermination = { [weak self] _ in
Task { await self?.removeSnapshotContinuation(id) }
}
}
}
public func setEnabled(_ enabled: Bool) async {
let wasEnabled = isEnabled
cancelRetry()
defaults.set(enabled, forKey: Self.enabledKey)
if enabled {
await syncTokenIfPossible()
} else {
await unregisterFromServer()
publish(.disabled)
if wasEnabled {
await unregisterFromServer()
} else {
await retryPendingUnregisterIfPossible()
}
}
}
public func register(deviceToken: Data) async {
let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
let previousToken = cachedTokenHex
if let previousToken,
previousToken != hex,
let previousOwner = defaults.string(
forKey: Self.registeredAccountIDKey
),
!previousOwner.isEmpty {
// Rotation does not prove the old row disappeared. Preserve its
// cleanup before replacing the cache, then make the new token
// ready before attempting the old-token DELETE.
persistPendingUnregister(
tokenHex: previousToken,
accountID: previousOwner
)
defaults.removeObject(forKey: Self.registeredAccountIDKey)
}
defaults.set(hex, forKey: Self.cachedTokenKey)
guard isEnabled else { return }
guard isEnabled else {
publish(.disabled)
return
}
cancelRetry()
await upload(tokenHex: hex)
if snapshotValue.backendState == .registered {
await retryPendingUnregisterIfPossible()
}
}
public func syncTokenIfPossible() async {
guard isEnabled, let hex = cachedTokenHex else { return }
guard isEnabled else {
await retryPendingUnregisterIfPossible()
publish(.disabled)
return
}
guard let hex = cachedTokenHex else {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: false,
backendState: .awaitingDeviceToken
))
// There is no current registration to prioritize, so an
// owner-matching privacy cleanup can proceed immediately.
await retryPendingUnregisterIfPossible()
return
}
cancelRetry()
await upload(tokenHex: hex)
// Current-account registration is the readiness-critical operation.
// Historical cleanup follows it, with its own bounded attempt budget.
if snapshotValue.backendState == .registered {
await retryPendingUnregisterIfPossible()
}
}
public func unregisterFromServer() async {
cancelRetry()
guard let hex = cachedTokenHex else { return }
await sendDelete(tokenHex: hex)
let session = try? await tokenProvider.authenticatedSessionSnapshot()
let ownerID = defaults.string(
forKey: Self.registeredAccountIDKey
) ?? session?.accountID
guard let ownerID, !ownerID.isEmpty else { return }
// Persist before requiring live auth. This is the privacy guarantee for
// an offline or signed-out opt-out.
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
// A token acknowledged for account A must never be deleted using
// account B credentials. Its tombstone waits for A to return.
guard let session, session.accountID == ownerID else { return }
if await sendDelete(tokenHex: hex, sessionSnapshot: session) {
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
}
}
/// Delete the device token from the server at sign-out, authenticating
@@ -93,7 +213,39 @@ public actor PushRegistrationService: PushRegistering {
/// - accessToken: The captured (or teardown-minted) access token.
/// - refreshToken: The captured refresh token.
public func unregisterFromServer(accessToken: String?, refreshToken: String?) async {
await unregisterFromServer(
accountID: nil,
accessToken: accessToken,
refreshToken: refreshToken
)
}
/// Sign-out variant with the account id captured before local auth clear.
public func unregisterFromServer(
accountID capturedAccountID: String?,
accessToken: String?,
refreshToken: String?
) async {
cancelRetry()
guard let hex = cachedTokenHex else { return }
let registeredOwnerID = defaults.string(
forKey: Self.registeredAccountIDKey
)
let ownerID = registeredOwnerID ?? capturedAccountID
if let ownerID, !ownerID.isEmpty {
// Persist the recovery record before validating credentials.
// Offline sign-out commonly has only the refresh token, but a
// later sign-in to this same account can safely finish the DELETE.
persistPendingUnregister(tokenHex: hex, accountID: ownerID)
}
if let registeredOwnerID,
capturedAccountID != registeredOwnerID {
// The legacy overload has no account identity, and a caller
// explicitly carrying B must never apply B's credentials to A's
// acknowledged token. Keep A's tombstone until A returns.
pushLog.info("Skipping push-token unregister: captured account does not prove registered ownership")
return
}
// Sign-out path: never fall back to the live token provider. The
// local-first sign-out cleared it, and a sign-in racing the bounded
// teardown can repopulate it with the NEXT account's tokens; the
@@ -104,7 +256,21 @@ public actor PushRegistrationService: PushRegistering {
pushLog.info("Skipping push-token unregister at sign-out: captured credentials incomplete")
return
}
await sendDelete(tokenHex: hex, capturedAccessToken: accessToken, capturedRefreshToken: refreshToken)
if await sendDelete(
tokenHex: hex,
capturedAccessToken: accessToken,
capturedRefreshToken: refreshToken
), let ownerID {
clearPendingUnregister(tokenHex: hex, accountID: ownerID)
clearRegisteredOwner(accountID: ownerID, tokenHex: hex)
}
if isEnabled {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registrationRequired
))
}
}
private var cachedTokenHex: String? {
@@ -113,7 +279,28 @@ public actor PushRegistrationService: PushRegistering {
}
private func upload(tokenHex: String) async {
guard let request = await makeRequest(
operationGeneration = UUID()
let generation = operationGeneration
await attemptUpload(
tokenHex: tokenHex,
generation: generation,
remainingDelays: retryDelays
)
}
private func attemptUpload(
tokenHex: String,
generation: UUID,
remainingDelays: [Duration]
) async {
guard isEnabled, generation == operationGeneration,
cachedTokenHex == tokenHex else { return }
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registering
))
let request = await makeRequest(
method: "POST",
path: "/api/device-tokens",
body: [
@@ -122,23 +309,199 @@ public actor PushRegistrationService: PushRegistering {
"environment": apnsEnvironment,
"platform": "ios",
]
) else { return }
await perform(request, label: "register")
)
let result: RegistrationResult
let requestSession: AuthenticatedSessionSnapshot?
switch request {
case let .success(context):
requestSession = context.session
result = await performRegistration(context.request)
case let .failure(failure):
requestSession = nil
result = .failure(failure, retryAfter: nil)
}
let operationIsCurrent = isEnabled
&& generation == operationGeneration
&& cachedTokenHex == tokenHex
let sessionIsCurrent: Bool
if let requestSession {
sessionIsCurrent = await tokenProvider
.isAuthenticatedSessionCurrent(requestSession)
} else {
sessionIsCurrent = false
}
if case .success = result,
let requestSession,
(!operationIsCurrent || !sessionIsCurrent) {
await reconcileStaleSuccessfulRegistration(
tokenHex: tokenHex,
staleSession: requestSession
)
return
}
guard operationIsCurrent else { return }
if requestSession != nil, !sessionIsCurrent {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(.authenticationRequired)
))
return
}
switch result {
case let .success(pushServiceConfigured):
if let requestSession {
defaults.set(
requestSession.accountID,
forKey: Self.registeredAccountIDKey
)
}
// The token is globally unique. A successful upsert onto the
// current account also removes any old-account association, so a
// pending tombstone for this token is fulfilled without applying
// old credentials.
for pending in pendingUnregisters where pending.tokenHex == tokenHex {
clearPendingUnregister(
tokenHex: pending.tokenHex,
accountID: pending.accountID
)
}
if pushServiceConfigured {
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .registered
))
} else {
// The API committed ownership before reporting its provider
// readiness. Retain that cleanup identity while failing the
// user-facing readiness check closed and retrying recovery.
let failure = PushRegistrationFailure.serviceUnavailable
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(failure)
))
scheduleUploadRetry(
failure: failure,
retryAfter: nil,
tokenHex: tokenHex,
generation: generation,
remainingDelays: remainingDelays
)
}
case let .failure(failure, retryAfter):
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: true,
backendState: .failed(failure)
))
scheduleUploadRetry(
failure: failure,
retryAfter: retryAfter,
tokenHex: tokenHex,
generation: generation,
remainingDelays: remainingDelays
)
}
}
private func scheduleUploadRetry(
failure: PushRegistrationFailure,
retryAfter: Duration?,
tokenHex: String,
generation: UUID,
remainingDelays: [Duration]
) {
guard failure.isRecoverable, !remainingDelays.isEmpty else { return }
let fallbackDelay = remainingDelays[0]
let delay = retryAfter ?? Self.jittered(
fallbackDelay,
multiplier: retryJitter(0.8...1.2)
)
let laterDelays = Array(remainingDelays.dropFirst())
retryTask = Task { [weak self, retrySleep] in
do {
try await retrySleep(delay)
} catch {
return
}
guard !Task.isCancelled else { return }
await self?.attemptUpload(
tokenHex: tokenHex,
generation: generation,
remainingDelays: laterDelays
)
}
}
/// Repairs the backend after an invalidated POST still succeeds.
///
/// URLSession cancellation cannot prove that the server did not commit the
/// request. Delete with the exact stale account credentials after its
/// acknowledgement, then re-upsert the token for whichever account is
/// current now. This orders A POST, A DELETE, B POST and therefore makes B
/// the final owner even when A's response arrives last.
private func reconcileStaleSuccessfulRegistration(
tokenHex: String,
staleSession: AuthenticatedSessionSnapshot
) async {
let currentSession = try? await tokenProvider
.authenticatedSessionSnapshot()
if isEnabled,
cachedTokenHex == tokenHex,
currentSession?.accountID == staleSession.accountID {
// A newer operation for the same account and token already
// represents the same backend ownership. Do not disturb it.
return
}
persistPendingUnregister(
tokenHex: tokenHex,
accountID: staleSession.accountID
)
if await sendDelete(
tokenHex: tokenHex,
capturedAccessToken: staleSession.accessToken,
capturedRefreshToken: staleSession.refreshToken
) {
clearPendingUnregister(
tokenHex: tokenHex,
accountID: staleSession.accountID
)
clearRegisteredOwner(
accountID: staleSession.accountID,
tokenHex: tokenHex
)
}
guard isEnabled, let currentToken = cachedTokenHex,
let currentSession = try? await tokenProvider
.authenticatedSessionSnapshot(),
await tokenProvider.isAuthenticatedSessionCurrent(currentSession)
else { return }
await upload(tokenHex: currentToken)
}
private func sendDelete(
tokenHex: String,
capturedAccessToken: String? = nil,
capturedRefreshToken: String? = nil
) async {
guard let request = await makeRequest(
capturedRefreshToken: String? = nil,
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
) async -> Bool {
guard case let .success(context) = await makeRequest(
method: "DELETE",
path: "/api/device-tokens",
body: ["deviceToken": tokenHex],
capturedAccessToken: capturedAccessToken,
capturedRefreshToken: capturedRefreshToken
) else { return }
await perform(request, label: "unregister")
capturedRefreshToken: capturedRefreshToken,
sessionSnapshot: sessionSnapshot
) else { return false }
guard await performDelete(context.request) else { return false }
if let session = context.session {
return await tokenProvider.isAuthenticatedSessionCurrent(session)
}
return true
}
private func makeRequest(
@@ -146,42 +509,407 @@ public actor PushRegistrationService: PushRegistering {
path: String,
body: [String: String],
capturedAccessToken: String? = nil,
capturedRefreshToken: String? = nil
) async -> URLRequest? {
capturedRefreshToken: String? = nil,
sessionSnapshot: AuthenticatedSessionSnapshot? = nil
) async -> Result<PushRequest, PushRegistrationFailure> {
let accessToken: String
let refreshToken: String
if let capturedAccessToken, let capturedRefreshToken {
let authenticatedSession: AuthenticatedSessionSnapshot?
if let sessionSnapshot {
accessToken = sessionSnapshot.accessToken
refreshToken = sessionSnapshot.refreshToken
authenticatedSession = sessionSnapshot
} else if let capturedAccessToken, let capturedRefreshToken {
// Sign-out path: the live provider is already cleared by the
// local-first sign-out; the captured pair is the only credential.
accessToken = capturedAccessToken
refreshToken = capturedRefreshToken
authenticatedSession = nil
} else {
do {
accessToken = try await tokenProvider.accessToken()
let session = try await tokenProvider
.authenticatedSessionSnapshot()
accessToken = session.accessToken
refreshToken = session.refreshToken
authenticatedSession = session
} catch {
return nil
return .failure(.authenticationRequired)
}
guard let liveRefreshToken = await tokenProvider.refreshToken() else { return nil }
refreshToken = liveRefreshToken
}
guard let url = URL(string: apiBaseURL + path) else { return nil }
guard let url = URL(string: apiBaseURL + path) else {
return .failure(.invalidConfiguration)
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
return request
request.timeoutInterval = 15
return .success(PushRequest(
request: request,
session: authenticatedSession
))
}
private func perform(_ request: URLRequest, label: String) async {
private func performRegistration(_ request: URLRequest) async -> RegistrationResult {
let redirectDelegate = RedirectMethodPreservingDelegate()
do {
let (_, response) = try await session.data(for: request)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
pushLog.error("\(label, privacy: .public) failed status=\(http.statusCode, privacy: .public)")
let (data, response) = try await session.data(
for: request,
delegate: redirectDelegate
)
guard let http = response as? HTTPURLResponse else {
return .failure(.invalidServerResponse, retryAfter: nil)
}
guard (200...299).contains(http.statusCode) else {
return Self.failureResult(statusCode: http.statusCode, response: http, data: data)
}
guard let acknowledgement = try? JSONDecoder().decode(
RegistrationAcknowledgement.self,
from: data
), acknowledgement.ok else {
return .failure(.invalidServerResponse, retryAfter: nil)
}
return .success(
pushServiceConfigured:
acknowledgement.pushServiceConfigured != false
)
} catch {
pushLog.error("\(label, privacy: .public) error=\(error.localizedDescription, privacy: .private)")
if redirectDelegate.refusedRedirect {
return .failure(.invalidServerResponse, retryAfter: nil)
}
pushLog.error("register transport failure")
return .failure(.networkUnavailable, retryAfter: nil)
}
}
private func performDelete(_ request: URLRequest) async -> Bool {
let redirectDelegate = RedirectMethodPreservingDelegate()
do {
let (data, response) = try await session.data(
for: request,
delegate: redirectDelegate
)
if let http = response as? HTTPURLResponse,
!(200...299).contains(http.statusCode) {
pushLog.error(
"unregister failed status=\(http.statusCode, privacy: .public)"
)
return false
}
guard response is HTTPURLResponse,
let acknowledgement = try? JSONDecoder().decode(
RegistrationAcknowledgement.self,
from: data
),
acknowledgement.ok
else {
pushLog.error("unregister acknowledgement invalid")
return false
}
return true
} catch {
pushLog.error("unregister transport failure")
return false
}
}
private func retryPendingUnregisterIfPossible() async {
guard let session = try? await tokenProvider
.authenticatedSessionSnapshot() else { return }
let currentAccountID = session.accountID
let matching = pendingUnregisters.filter {
$0.accountID == currentAccountID
}
let batch = Array(
matching.prefix(Self.pendingUnregisterAttemptBudget)
)
let results = await withTaskGroup(
of: (PendingUnregister, Bool).self,
returning: [(PendingUnregister, Bool)].self
) { group in
for pending in batch {
group.addTask { [self] in
(
pending,
await sendDelete(
tokenHex: pending.tokenHex,
sessionSnapshot: session
)
)
}
}
var results: [(PendingUnregister, Bool)] = []
for await result in group {
results.append(result)
}
return results
}
for (pending, succeeded) in results where succeeded {
clearPendingUnregister(
tokenHex: pending.tokenHex,
accountID: pending.accountID
)
clearRegisteredOwner(
accountID: pending.accountID,
tokenHex: pending.tokenHex
)
}
if matching.count > batch.count,
results.contains(where: { $0.1 }) {
schedulePendingUnregisterContinuation()
}
}
private func persistPendingUnregister(tokenHex: String, accountID: String) {
let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID)
var queue = pendingUnregisters
if !queue.contains(entry) {
queue.append(entry)
}
// Never evict a privacy cleanup obligation merely to enforce a local
// storage cap. The set is deduplicated by (account, token), and drains
// in bounded network batches so size cannot stall current readiness.
storePendingUnregisters(queue)
}
private func schedulePendingUnregisterContinuation() {
guard unregisterDrainTask == nil else { return }
unregisterDrainTask = Task { [weak self] in
await Task.yield()
guard !Task.isCancelled, let self else { return }
await self.runPendingUnregisterContinuation()
}
}
private func runPendingUnregisterContinuation() async {
unregisterDrainTask = nil
await retryPendingUnregisterIfPossible()
}
private func clearPendingUnregister(
tokenHex: String,
accountID: String
) {
let filtered = pendingUnregisters.filter { entry in
entry.tokenHex != tokenHex || entry.accountID != accountID
}
storePendingUnregisters(filtered)
}
private var pendingUnregisters: [PendingUnregister] {
let entries: [PendingUnregister]
if let data = defaults.data(forKey: Self.pendingUnregisterQueueKey),
let decoded = try? JSONDecoder().decode(
[PendingUnregister].self,
from: data
) {
entries = decoded
} else {
entries = []
}
var seen = Set<PendingUnregister>()
return entries.filter { seen.insert($0).inserted }
}
private static func migrateLegacyPendingUnregisters(
in defaults: UserDefaults
) {
guard let tokenHex = defaults.string(
forKey: pendingUnregisterTokenKey
), let accountID = defaults.string(
forKey: pendingUnregisterAccountIDKey
), !tokenHex.isEmpty, !accountID.isEmpty else { return }
var entries = (defaults.data(forKey: pendingUnregisterQueueKey)
.flatMap { try? JSONDecoder().decode(
[PendingUnregister].self,
from: $0
) }) ?? []
let legacy = PendingUnregister(
tokenHex: tokenHex,
accountID: accountID
)
if !entries.contains(legacy) { entries.append(legacy) }
if let data = try? JSONEncoder().encode(entries) {
defaults.set(data, forKey: pendingUnregisterQueueKey)
}
defaults.removeObject(forKey: pendingUnregisterTokenKey)
defaults.removeObject(forKey: pendingUnregisterAccountIDKey)
}
private func storePendingUnregisters(_ entries: [PendingUnregister]) {
if entries.isEmpty {
defaults.removeObject(forKey: Self.pendingUnregisterQueueKey)
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
return
}
if let data = try? JSONEncoder().encode(entries) {
defaults.set(data, forKey: Self.pendingUnregisterQueueKey)
}
defaults.removeObject(forKey: Self.pendingUnregisterTokenKey)
defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey)
}
private func clearRegisteredOwner(
accountID: String,
tokenHex: String
) {
guard cachedTokenHex == tokenHex,
defaults.string(
forKey: Self.registeredAccountIDKey
) == accountID else {
return
}
defaults.removeObject(forKey: Self.registeredAccountIDKey)
}
public func deviceTokenRegistrationFailed() {
cancelRetry()
guard isEnabled else {
publish(.disabled)
return
}
publish(PushRegistrationSnapshot(
isEnabled: true,
hasDeviceToken: cachedTokenHex != nil,
backendState: .deviceTokenRegistrationFailed
))
}
private func cancelRetry() {
operationGeneration = UUID()
retryTask?.cancel()
retryTask = nil
}
private func publish(_ snapshot: PushRegistrationSnapshot) {
guard snapshotValue != snapshot else { return }
snapshotValue = snapshot
for continuation in snapshotContinuations.values {
continuation.yield(snapshot)
}
}
private func removeSnapshotContinuation(_ id: UUID) {
snapshotContinuations.removeValue(forKey: id)
}
private static func failureResult(
statusCode: Int,
response: HTTPURLResponse,
data: Data
) -> RegistrationResult {
switch statusCode {
case 300...399:
return .failure(.invalidServerResponse, retryAfter: nil)
case 408, 425:
let seconds = retryAfterSeconds(response: response, body: data)
return .failure(
.serviceUnavailable,
retryAfter: seconds.map(Duration.seconds)
)
case 401:
return .failure(.authenticationRequired, retryAfter: nil)
case 409:
let body = try? JSONDecoder().decode(
RegistrationErrorResponse.self,
from: data
)
if body?.error == "push_delivery_in_progress" {
let seconds = retryAfterSeconds(
response: response,
body: data
)
return .failure(
.serviceUnavailable,
retryAfter: seconds.map(Duration.seconds)
)
}
return .failure(.accountDeletionInProgress, retryAfter: nil)
case 429:
let body = try? JSONDecoder().decode(
RegistrationErrorResponse.self,
from: data
)
if body?.error == "too_many_devices" {
return .failure(
.deviceLimitReached(limit: max(1, body?.limit ?? 200)),
retryAfter: nil
)
}
let seconds = retryAfterSeconds(
response: response,
body: data
)
return .failure(
.rateLimited(retryAfterSeconds: seconds),
retryAfter: seconds.map(Duration.seconds)
)
case 500...599:
return .failure(.serviceUnavailable, retryAfter: nil)
default:
return .failure(.rejected(statusCode: statusCode), retryAfter: nil)
}
}
private static func retryAfterSeconds(
response: HTTPURLResponse,
body: Data
) -> Int? {
let headerDelay = response.value(forHTTPHeaderField: "Retry-After")
.flatMap(Int.init)
let bodyDelay = try? JSONDecoder().decode(
RegistrationErrorResponse.self,
from: body
).retryAfterSeconds
guard let raw = headerDelay ?? bodyDelay else { return nil }
return min(max(raw, 0), 600)
}
private static func jittered(_ duration: Duration, multiplier: Double) -> Duration {
let components = duration.components
let seconds = Double(components.seconds)
+ Double(components.attoseconds) / 1_000_000_000_000_000_000
let nanoseconds = seconds * multiplier * 1_000_000_000
guard nanoseconds.isFinite else {
return .nanoseconds(nanoseconds.sign == .minus
? Int64.min
: Int64.max)
}
if nanoseconds >= Double(Int64.max) {
return .nanoseconds(Int64.max)
}
if nanoseconds <= Double(Int64.min) {
return .nanoseconds(Int64.min)
}
return .nanoseconds(Int64(nanoseconds))
}
}
private enum RegistrationResult {
case success(pushServiceConfigured: Bool)
case failure(PushRegistrationFailure, retryAfter: Duration?)
}
private struct PushRequest {
let request: URLRequest
let session: AuthenticatedSessionSnapshot?
}
private struct RegistrationAcknowledgement: Decodable {
let ok: Bool
let pushServiceConfigured: Bool?
}
private struct RegistrationErrorResponse: Decodable {
let error: String?
let retryAfterSeconds: Int?
let limit: Int?
}
private struct PendingUnregister: Codable, Hashable {
let tokenHex: String
let accountID: String
}
@@ -0,0 +1,90 @@
/// A backend device-token registration failure safe to expose in UI and logs.
public enum PushRegistrationFailure: Error, Sendable, Equatable {
/// The native session could not supply a valid access/refresh token pair.
case authenticationRequired
/// Account deletion currently blocks user-scoped mutations.
case accountDeletionInProgress
/// The server asked the client to wait before retrying.
case rateLimited(retryAfterSeconds: Int?)
/// The account already has the maximum number of unpruned device tokens.
case deviceLimitReached(limit: Int)
/// The request could not reach the API.
case networkUnavailable
/// The API or APNs relay is temporarily unavailable.
case serviceUnavailable
/// The configured API base URL is invalid.
case invalidConfiguration
/// A successful HTTP response did not contain the registration acknowledgement.
case invalidServerResponse
/// The API permanently rejected the registration request.
case rejected(statusCode: Int)
/// Whether repeating the same registration later can reasonably succeed.
public var isRecoverable: Bool {
switch self {
case .rateLimited, .networkUnavailable, .serviceUnavailable:
true
case .authenticationRequired, .accountDeletionInProgress, .deviceLimitReached,
.invalidConfiguration, .invalidServerResponse, .rejected:
false
}
}
}
/// The furthest backend stage a push-enabled iOS installation has confirmed.
public enum PushRegistrationBackendState: Sendable, Equatable {
/// The OS has not supplied an APNs device token yet.
case awaitingDeviceToken
/// A cached APNs token exists but has not been acknowledged this launch.
case registrationRequired
/// iOS failed to acquire a current APNs token. A user-triggered retry can
/// call `registerForRemoteNotifications()` again.
case deviceTokenRegistrationFailed
/// A device-token request is currently in flight.
case registering
/// The API acknowledged the current APNs token.
case registered
/// Registration stopped at a typed, user-visible failure.
case failed(PushRegistrationFailure)
/// Whether the current state can recover automatically.
public var isRecoverable: Bool {
if case let .failed(failure) = self {
return failure.isRecoverable
}
return false
}
}
/// Truthful local and backend push-registration readiness.
public struct PushRegistrationSnapshot: Sendable, Equatable {
/// Whether the user explicitly opted into phone notifications.
public let isEnabled: Bool
/// Whether this install has acquired an APNs device token.
public let hasDeviceToken: Bool
/// The backend acknowledgement stage for the current token.
public let backendState: PushRegistrationBackendState
/// Creates a push-registration snapshot.
///
/// - Parameters:
/// - isEnabled: Whether the user explicitly opted in.
/// - hasDeviceToken: Whether APNs supplied a token.
/// - backendState: The furthest confirmed backend stage.
public init(
isEnabled: Bool,
hasDeviceToken: Bool,
backendState: PushRegistrationBackendState
) {
self.isEnabled = isEnabled
self.hasDeviceToken = hasDeviceToken
self.backendState = backendState
}
/// The canonical disabled snapshot.
public static let disabled = PushRegistrationSnapshot(
isEnabled: false,
hasDeviceToken: false,
backendState: .awaitingDeviceToken
)
}
@@ -0,0 +1,127 @@
public import Foundation
import OSLog
nonisolated private let pushRedirectLog = Logger(
subsystem: "ai.manaflow.cmux",
category: "push.redirect"
)
/// Preserves mutating push requests across safe redirects and rejects unsafe hops.
///
/// Foundation normally rewrites `POST` and `DELETE` to a body-less `GET` for
/// 301/302 responses. That turns a successful redirect target into a false
/// registration/send acknowledgement. This delegate restores the original
/// method, body, and headers for same-origin 301/302 responses. Same-origin
/// 307/308 requests already preserve the method, but their credential headers
/// are re-applied defensively.
///
/// Every cross-origin redirect is refused before credentials or notification
/// data reach the target. A 303 from a mutating request is refused because its
/// body-less GET cannot acknowledge that the original mutation completed.
public final class RedirectMethodPreservingDelegate:
NSObject,
URLSessionTaskDelegate,
@unchecked Sendable
{
// URLSession's redirect delegate callback and the public refusal read are
// both synchronous on different executors, so an actor cannot own this
// single-bit handoff without changing either API to async.
private let refusalLock = NSLock()
private var refusedRedirectValue = false
/// Creates a stateless per-owner redirect delegate.
public override init() {
super.init()
}
/// Whether this request was stopped by the redirect policy.
public var refusedRedirect: Bool {
refusalLock.withLock { refusedRedirectValue }
}
/// Applies the redirect policy to one URL loading task.
public func urlSession(
_ session: URLSession,
task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse,
newRequest proposedRequest: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
guard let original = task.originalRequest,
Self.sameOrigin(original.url, proposedRequest.url) else {
markRefused()
pushRedirectLog.error(
"Refused cross-origin push redirect status=\(response.statusCode, privacy: .public)"
)
completionHandler(nil)
return
}
let originalMethod = original.httpMethod?.uppercased() ?? "GET"
let isMutation = Self.mutatingMethods.contains(originalMethod)
if response.statusCode == 303, isMutation {
markRefused()
pushRedirectLog.error("Refused mutating push 303 redirect")
completionHandler(nil)
return
}
guard isMutation, (301...302).contains(response.statusCode)
|| (307...308).contains(response.statusCode) else {
completionHandler(proposedRequest)
return
}
guard original.httpBodyStream == nil else {
markRefused()
pushRedirectLog.error("Refused non-replayable push redirect body")
completionHandler(nil)
return
}
var preserved = proposedRequest
preserved.httpMethod = originalMethod
preserved.httpBody = original.httpBody
for (field, value) in original.allHTTPHeaderFields ?? [:] {
preserved.setValue(value, forHTTPHeaderField: field)
}
pushRedirectLog.info(
"Preserved push \(originalMethod, privacy: .public) across status=\(response.statusCode, privacy: .public)"
)
completionHandler(preserved)
}
private func markRefused() {
refusalLock.withLock {
refusedRedirectValue = true
}
}
private static let mutatingMethods = Set(["POST", "PUT", "PATCH", "DELETE"])
static func sameOrigin(_ lhs: URL?, _ rhs: URL?) -> Bool {
guard let lhs, let rhs else { return false }
guard let lhsScheme = lhs.scheme?.lowercased(),
let rhsScheme = rhs.scheme?.lowercased(),
["http", "https"].contains(lhsScheme),
["http", "https"].contains(rhsScheme),
let lhsHost = lhs.host?.lowercased(),
let rhsHost = rhs.host?.lowercased(),
!lhsHost.isEmpty,
!rhsHost.isEmpty
else { return false }
return lhsScheme == rhsScheme
&& lhsHost == rhsHost
&& effectivePort(lhs) == effectivePort(rhs)
}
private static func effectivePort(_ url: URL) -> Int? {
if let port = url.port { return port }
switch url.scheme?.lowercased() {
case "https", "wss":
return 443
case "http", "ws":
return 80
default:
return nil
}
}
}
@@ -206,17 +206,16 @@ import Testing
}
await client.storedAccessDidPark()
// The late exchange's write is what sign-out's credential capture has to
// race, so wait for the store to hold exchange 2's tokens (or to have
// been cleared) before releasing the capture. The client resumes this
// from the write, so the wait does not compete with it for CPU.
await client.releaseParkedCredential()
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(2))
while true {
let refresh = await client.refreshToken()
if refresh == "refresh-2" || refresh == nil { break }
if clock.now >= deadline {
preconditionFailure("Timed out waiting for late exchange cleanup to reach the token store")
}
await Task.yield()
let cleanupWatchdog = failAfterDeadline(.seconds(30)) {
"Timed out waiting for late exchange cleanup to reach the token store"
}
await client.tokensDidSettle(afterExchange: 2)
cleanupWatchdog.cancel()
await client.releaseParkedStoredAccess()
await signOut.value
@@ -36,6 +36,36 @@ import Testing
#expect(coordinator.currentUser == nil)
}
@Test(.timeLimit(.minutes(1)))
func sessionIdentityStreamPublishesSignInAndImmediateSignOut() async throws {
let user = CMUXAuthUser(
id: "u1",
primaryEmail: "[email protected]",
displayName: "A"
)
let (coordinator, _) = makeCoordinator(
client: FakeAuthClient(user: user)
)
var identities = coordinator.authenticatedSessionIdentities()
.makeAsyncIterator()
let initial = await identities.next()
#expect(initial != nil)
#expect(initial! == nil)
try await coordinator.signInWithPassword(
email: "[email protected]",
password: "pw"
)
let signedIn = try #require(await identities.next())
#expect(signedIn?.accountID == user.id)
await coordinator.signOut()
let signedOut = await identities.next()
#expect(signedOut != nil)
#expect(signedOut! == nil)
}
@Test func passwordSignInAuthenticatesAndCaches() async throws {
let user = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let client = FakeAuthClient(user: user)
@@ -50,6 +80,31 @@ import Testing
#expect(recorded?.email == "[email protected]")
}
@Test func emptyAccountIDNeverPublishesAnAuthenticatedIdentity() async throws {
let user = CMUXAuthUser(
id: "",
primaryEmail: "[email protected]",
displayName: "A"
)
let (coordinator, _) = makeCoordinator(
client: FakeAuthClient(user: user)
)
try await coordinator.signInWithPassword(
email: "[email protected]",
password: "pw"
)
#expect(coordinator.isAuthenticated)
#expect(coordinator.authenticatedSessionIdentity == nil)
#expect(!coordinator.isAuthenticatedSessionIdentityCurrent(
AuthenticatedSessionIdentity(
generation: coordinator.authSessionGeneration,
accountID: ""
)
))
}
@Test func everyAuthSessionTransitionClosesBeforeTheNextSessionPublishes() async throws {
let first = CMUXAuthUser(id: "u1", primaryEmail: "[email protected]", displayName: "A")
let second = CMUXAuthUser(id: "u2", primaryEmail: "[email protected]", displayName: "B")
@@ -28,6 +28,8 @@ actor GateableValidationAuthClient: AuthClient {
/// so tests can tell WHICH exchange's write the store currently holds:
/// exchange N stores `"access-N"` / `"refresh-N"` in write order.
private var exchangeCounter = 0
/// Tests awaiting a settled token store after a late exchange.
private var tokenWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var currentUserStartCount = 0
private let validationGate = Gate()
private let teamsGate = Gate()
@@ -63,6 +65,29 @@ actor GateableValidationAuthClient: AuthClient {
gate.parked.removeFirst().resume()
}
// MARK: - Token-store settling
/// Suspends until exchange `count` has written its tokens, or until a clear
/// emptied the store. Those are the two outcomes a late exchange racing
/// sign-out can produce, and both are written inside this actor, so the
/// waiter is resumed by the write instead of polling for it.
func tokensDidSettle(afterExchange count: Int) async {
if tokensSettled(afterExchange: count) { return }
await withCheckedContinuation { tokenWaiters.append((count, $0)) }
}
private func tokensSettled(afterExchange count: Int) -> Bool {
exchangeCounter >= count || refresh == nil
}
private func resumeSettledTokenWaiters() {
tokenWaiters.removeAll { waiter in
guard tokensSettled(afterExchange: waiter.count) else { return false }
waiter.continuation.resume()
return true
}
}
private func parkIfArmed(_ gate: Gate) async {
guard gate.armed else { return }
gate.armed = false
@@ -146,6 +171,7 @@ actor GateableValidationAuthClient: AuthClient {
exchangeCounter += 1
access = "access-\(exchangeCounter)"
refresh = "refresh-\(exchangeCounter)"
resumeSettledTokenWaiters()
}
func accessToken() async -> String? { access }
@@ -159,6 +185,7 @@ actor GateableValidationAuthClient: AuthClient {
exchangeCounter += 1
access = "access-\(exchangeCounter)"
refresh = "refresh-\(exchangeCounter)"
resumeSettledTokenWaiters()
}
func signInWithOAuth(provider: String, anchor: any AuthPresentationAnchoring) async throws {}
@@ -171,6 +198,7 @@ actor GateableValidationAuthClient: AuthClient {
await parkIfArmed(clearGate)
access = nil
refresh = nil
resumeSettledTokenWaiters()
}
func clearLocalSession(ifRefreshTokenMatches refreshToken: String) async {
@@ -181,6 +209,7 @@ actor GateableValidationAuthClient: AuthClient {
guard refresh == refreshToken else { return }
access = nil
refresh = nil
resumeSettledTokenWaiters()
}
func revokeSession(accessToken: String?, refreshToken: String?) async throws {}
@@ -17,6 +17,7 @@ actor FlowFakeAuthClient: AuthClient {
private var currentUserError: (any Error)?
private var userGateClosed = false
private var userGateWaiters: [CheckedContinuation<Void, Never>] = []
private var pendingUserRequestWaiters: [CheckedContinuation<Void, Never>] = []
private var storedAccessGateArmed = false
private var storedAccessParked: [CheckedContinuation<Void, Never>] = []
private var storedAccessParkWaiters: [CheckedContinuation<Void, Never>] = []
@@ -38,6 +39,12 @@ actor FlowFakeAuthClient: AuthClient {
for waiter in waiters { waiter.resume() }
}
/// Suspends until a `currentUser` read is parked on the closed user gate.
func pendingUserRequestDidPark() async {
if pendingUserRequests > 0 { return }
await withCheckedContinuation { pendingUserRequestWaiters.append($0) }
}
func armStoredAccessTokenGate() { storedAccessGateArmed = true }
/// Suspends until a `storedAccessToken` read is parked on the armed gate.
@@ -59,7 +66,12 @@ actor FlowFakeAuthClient: AuthClient {
func currentUser(throwOnMissing: Bool) async throws -> CMUXAuthUser? {
if userGateClosed {
pendingUserRequests += 1
await withCheckedContinuation { userGateWaiters.append($0) }
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
userGateWaiters.append(continuation)
let waiters = pendingUserRequestWaiters
pendingUserRequestWaiters = []
for waiter in waiters { waiter.resume() }
}
pendingUserRequests -= 1
}
if let currentUserError {
@@ -140,6 +152,14 @@ actor FlowInMemoryTokenStore: StackAuthTokenStoreProtocol {
final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
private(set) var sessions: [FakeBrowserAuthSession] = []
var nextStartResult = true
private var sessionWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
/// Suspends until the attempt has created at least `count` sessions, so a
/// test acts on a session that exists rather than polling for one.
func sessionsDidReach(_ count: Int) async {
if sessions.count >= count { return }
await withCheckedContinuation { sessionWaiters.append((count, $0)) }
}
func makeSession(
signInURL: URL,
@@ -153,8 +173,19 @@ final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory {
)
sessions.append(session)
nextStartResult = true
for waiter in takeSatisfiedSessionWaiters() { waiter.resume() }
return session
}
private func takeSatisfiedSessionWaiters() -> [CheckedContinuation<Void, Never>] {
var satisfied: [CheckedContinuation<Void, Never>] = []
sessionWaiters.removeAll { waiter in
guard sessions.count >= waiter.count else { return false }
satisfied.append(waiter.continuation)
return true
}
return satisfied
}
}
/// Delivers its completion exactly once, mirroring `ASWebAuthenticationSession`.
@@ -1,5 +1,6 @@
import CMUXAuthCore
import Foundation
import Observation
@testable import CmuxAuthRuntime
@MainActor
@@ -94,40 +95,48 @@ struct HostBrowserSignInFlowHarness {
.value ?? ""
}
func waitForSession(count: Int = 1, timeout: Duration = .seconds(2)) async {
// The attempt task runs on the same main actor; yielding lets it reach
// the browser-session continuation deterministically.
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while factory.sessions.count < count {
if clock.now >= deadline {
preconditionFailure(
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
)
}
await Task.yield()
/// Waits for the attempt to create `count` browser sessions. The factory
/// resumes this from the session it creates, so the wait costs no CPU while
/// the attempt runs.
func waitForSession(count: Int = 1, timeout: Duration = .seconds(30)) async {
let watchdog = failAfterDeadline(timeout) { [factory] in
"Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)"
}
await factory.sessionsDidReach(count)
watchdog.cancel()
}
func waitForCondition(timeout: Duration = .seconds(2), until condition: @MainActor () -> Bool) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
/// Waits until `condition` holds.
///
/// `condition` has to read observable state on the flow or the coordinator
/// (both are `@Observable` and main-actor isolated). The wait registers with
/// the observation system and suspends until one of the properties the
/// condition read is written, then re-checks; a condition over unobserved
/// state would never be woken and would hit the deadline below.
func waitForCondition(timeout: Duration = .seconds(30), until condition: @MainActor () -> Bool) async {
let watchdog = failAfterDeadline(timeout) {
"Timed out waiting for host-browser condition; it must read observable flow or coordinator state"
}
while !condition() {
if clock.now >= deadline {
preconditionFailure("Timed out waiting for host-browser condition")
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
withObservationTracking {
_ = condition()
} onChange: {
// Fires from the write itself, before the new value lands.
// Resuming here queues the re-check as a separate main-actor
// job, which cannot run until the write has finished.
continuation.resume()
}
}
await Task.yield()
}
watchdog.cancel()
}
func waitForPendingUserRequest(timeout: Duration = .seconds(2)) async {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while await client.pendingUserRequests == 0 {
if clock.now >= deadline {
preconditionFailure("Timed out waiting for a pending user request")
}
await Task.yield()
}
/// Waits for a `currentUser` read to park on the closed user gate. The fake
/// client resumes this as it parks.
func waitForPendingUserRequest(timeout: Duration = .seconds(30)) async {
let watchdog = failAfterDeadline(timeout) { "Timed out waiting for a pending user request" }
await client.pendingUserRequestDidPark()
watchdog.cancel()
}
}
@@ -0,0 +1,109 @@
import Foundation
/// End-to-end URL loading probe for mutating redirect behavior.
final class PushRedirectURLProtocol: URLProtocol, @unchecked Sendable {
enum Scenario: Sendable {
case sameOrigin301
case sameOrigin302
case sameOrigin303
case sameOrigin307
case sameOrigin308
case schemeDowngrade307
case crossHost308
case portChange302
var statusCode: Int {
switch self {
case .sameOrigin301:
301
case .sameOrigin302, .portChange302:
302
case .sameOrigin303:
303
case .sameOrigin307, .schemeDowngrade307:
307
case .sameOrigin308, .crossHost308:
308
}
}
}
static let state = PushRedirectState()
static let startHost = "push-start.test"
static let targetHost = "push-target.test"
static let startPath = "/api/device-tokens"
static let targetPath = "/canonical/device-tokens"
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Task {
guard let url = request.url else {
client?.urlProtocolDidFinishLoading(self)
return
}
let scenario = await Self.state.scenario
if url.path == Self.startPath {
let target: URL
switch scenario {
case .sameOrigin301, .sameOrigin302, .sameOrigin303,
.sameOrigin307, .sameOrigin308:
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.path = Self.targetPath
target = components.url!
case .schemeDowngrade307:
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.scheme = "http"
components.path = Self.targetPath
target = components.url!
case .crossHost308:
target = URL(string: "https://\(Self.targetHost)\(Self.targetPath)")!
case .portChange302:
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)!
components.port = 444
components.path = Self.targetPath
target = components.url!
}
let status = scenario.statusCode
let response = HTTPURLResponse(
url: url,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["Location": target.absoluteString]
)!
var proposed = URLRequest(url: target)
proposed.httpMethod = [307, 308].contains(status) ? request.httpMethod : "GET"
client?.urlProtocol(self, wasRedirectedTo: proposed, redirectResponse: response)
return
}
await Self.state.recordTarget(request)
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: nil
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data(#"{"ok":true}"#.utf8))
client?.urlProtocolDidFinishLoading(self)
}
}
override func stopLoading() {}
}
actor PushRedirectState {
private(set) var scenario: PushRedirectURLProtocol.Scenario = .sameOrigin301
private(set) var targetRequests: [URLRequest] = []
func reset(_ scenario: PushRedirectURLProtocol.Scenario) {
self.scenario = scenario
targetRequests = []
}
func recordTarget(_ request: URLRequest) {
targetRequests.append(request)
}
}
@@ -0,0 +1,211 @@
import Foundation
private struct PushRegistrationLoadingContext: @unchecked Sendable {
let loadingProtocol: PushRegistrationURLProtocol
}
/// Scripted transport for push-registration lifecycle tests.
///
/// `URLProtocol` is configured by type, so one actor-backed script is shared by
/// this serialized suite. The actor owns both the response queue and request
/// capture, keeping test mutation out of process-global unsafe variables.
final class PushRegistrationURLProtocol: URLProtocol, @unchecked Sendable {
struct Stub: Sendable {
let statusCode: Int?
let headers: [String: String]
let body: Data
let error: URLError?
let started: TestPhaseSignal?
let blocker: TestContinuationBlocker?
static func response(
_ statusCode: Int,
headers: [String: String] = [:],
json: String = #"{"ok":true}"#
) -> Stub {
Stub(
statusCode: statusCode,
headers: headers,
body: Data(json.utf8),
error: nil,
started: nil,
blocker: nil
)
}
static func gatedResponse(
_ statusCode: Int,
started: TestPhaseSignal,
blocker: TestContinuationBlocker,
headers: [String: String] = [:],
json: String = #"{"ok":true}"#
) -> Stub {
Stub(
statusCode: statusCode,
headers: headers,
body: Data(json.utf8),
error: nil,
started: started,
blocker: blocker
)
}
static func failure(_ code: URLError.Code) -> Stub {
Stub(
statusCode: nil,
headers: [:],
body: Data(),
error: URLError(code),
started: nil,
blocker: nil
)
}
}
static let script = PushRegistrationURLScript()
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let capturedRequest = request
let capturedBody = Self.bodyData(from: capturedRequest)
let stub = Self.script.take(capturedRequest, body: capturedBody)
let context = PushRegistrationLoadingContext(
loadingProtocol: self
)
if stub.error != nil {
Task.detached { [capturedRequest, context] in
await Task.yield()
context.complete(stub, request: capturedRequest)
}
return
}
guard stub.started != nil || stub.blocker != nil else {
context.complete(stub, request: capturedRequest)
return
}
Task.detached { [capturedRequest, context] in
await stub.started?.markStarted()
await stub.blocker?.wait()
context.complete(stub, request: capturedRequest)
}
}
override func stopLoading() {}
private static func bodyData(from request: URLRequest) -> Data? {
if let body = request.httpBody {
return body
}
guard let stream = request.httpBodyStream else { return nil }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 1_024
let buffer = UnsafeMutablePointer<UInt8>.allocate(
capacity: bufferSize
)
defer { buffer.deallocate() }
while stream.hasBytesAvailable {
let count = stream.read(buffer, maxLength: bufferSize)
if count <= 0 { break }
data.append(buffer, count: count)
}
return data
}
}
private extension PushRegistrationLoadingContext {
func complete(
_ stub: PushRegistrationURLProtocol.Stub,
request: URLRequest
) {
if let error = stub.error {
loadingProtocol.client?.urlProtocol(
loadingProtocol,
didFailWithError: error
)
return
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: stub.statusCode ?? 500,
httpVersion: "HTTP/1.1",
headerFields: stub.headers
)!
loadingProtocol.client?.urlProtocol(
loadingProtocol,
didReceive: response,
cacheStoragePolicy: .notAllowed
)
if !stub.body.isEmpty {
loadingProtocol.client?.urlProtocol(
loadingProtocol,
didLoad: stub.body
)
}
loadingProtocol.client?.urlProtocolDidFinishLoading(
loadingProtocol
)
}
}
final class PushRegistrationURLScript: @unchecked Sendable {
private let lock = NSLock()
private var stubs: [PushRegistrationURLProtocol.Stub] = []
private var capturedRequests: [URLRequest] = []
private var capturedBodies: [Data?] = []
var requests: [URLRequest] {
get async {
lock.withLock { capturedRequests }
}
}
var requestBodies: [Data?] {
get async {
lock.withLock { capturedBodies }
}
}
func waitForRequestCount(
_ expectedCount: Int,
timeout: Duration = .seconds(1)
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: timeout)
while lock.withLock({ capturedRequests.count }) < expectedCount {
guard clock.now < deadline else { return false }
try? await clock.sleep(for: .milliseconds(1))
}
return true
}
func reset(
_ nextStubs: [PushRegistrationURLProtocol.Stub]
) async {
lock.withLock {
stubs = nextStubs
capturedRequests = []
capturedBodies = []
}
}
func take(
_ request: URLRequest,
body: Data?
) -> PushRegistrationURLProtocol.Stub {
lock.lock()
defer { lock.unlock() }
capturedRequests.append(request)
capturedBodies.append(body)
guard !stubs.isEmpty else {
return .response(
500,
json: #"{"error":"unscripted_request"}"#
)
}
return stubs.removeFirst()
}
}
@@ -0,0 +1,51 @@
import Foundation
import Testing
@testable import CmuxAuthRuntime
struct RedirectMethodPreservingDelegateTests {
@Test(arguments: [
("relative/path", "other/path"),
("opaque:first", "opaque:second"),
("file:///tmp/source", "file:///tmp/target"),
("ws://example.test/source", "ws://example.test/target"),
("wss://example.test/source", "wss://example.test/target"),
("https://example.test/source", "https://other.test/target"),
("https://example.test/source", "http://example.test/target"),
("https://example.test/source", "https://example.test:8443/target"),
("https://example.test/source", "https://sub.example.test/target"),
])
func nonHTTPOriginsFailClosed(
source: String,
target: String
) throws {
let sourceURL = try #require(URL(string: source))
let targetURL = try #require(URL(string: target))
#expect(
!RedirectMethodPreservingDelegate.sameOrigin(
sourceURL,
targetURL
)
)
}
@Test(arguments: [
("https://example.test/source", "https://example.test/target"),
("https://example.test:443/source", "https://example.test/target"),
("http://example.test:80/source", "http://EXAMPLE.test/target"),
])
func canonicalHTTPOriginsMatch(
source: String,
target: String
) throws {
let sourceURL = try #require(URL(string: source))
let targetURL = try #require(URL(string: target))
#expect(
RedirectMethodPreservingDelegate.sameOrigin(
sourceURL,
targetURL
)
)
}
}
@@ -0,0 +1,21 @@
import Foundation
/// Aborts the run with `message` unless the caller cancels the returned task
/// first.
///
/// The waits this guards are event-driven: they suspend until the fake they
/// wait on resumes them, so a run that reaches this deadline is one where the
/// awaited edge never arrived. Reporting that by name beats leaving the run
/// suspended forever. The deadline is generous on purpose it never bounds a
/// passing run, so machine load cannot push a healthy wait past it.
@MainActor
func failAfterDeadline(
_ timeout: Duration,
_ message: @escaping @MainActor () -> String
) -> Task<Void, Never> {
Task { @MainActor in
try? await Task.sleep(for: timeout)
guard !Task.isCancelled else { return }
preconditionFailure(message())
}
}
@@ -36,7 +36,7 @@ struct CmxAuthoritativeDiscoveryResolver: Sendable {
knownRevision: cached?.revision
)
if let snapshot = response.snapshot,
response.snapshotComplete == true {
response.snapshotIsComplete {
try Self.requireRevision(snapshot, atLeast: minimumRevision)
if !response.reset {
try Self.requireRevision(snapshot, atLeast: cached?.revision)
@@ -0,0 +1,24 @@
extension CmxConnectivityDiscoveryScope {
/// The caller's own binding, which remains visible even when it does not
/// satisfy the peer selector.
public struct LocalBinding: Codable, Equatable, Sendable {
/// The durable device identifier that owns the local endpoint.
public let deviceID: String
/// The app installation identifier that owns the local endpoint.
public let appInstanceID: String
/// The exact build tag registered by the local app.
public let tag: String
/// The platform hosting the local endpoint.
public let platform: CmxIrohPlatform
private enum CodingKeys: String, CodingKey {
case deviceID = "device_id"
case appInstanceID = "app_instance_id"
case tag
case platform
}
}
}
@@ -0,0 +1,19 @@
extension CmxConnectivityDiscoveryScope {
/// Opposite-platform bindings that this runtime may connect to or admit.
public struct PeerBindings: Codable, Equatable, Sendable {
/// The platform required for every selected peer binding.
public let platform: CmxIrohPlatform
/// Canonical build tags accepted by the peer selector, or all tags when absent.
public let tags: [String]?
/// The required pairing state, or either state when absent.
public let pairingEnabled: Bool?
private enum CodingKeys: String, CodingKey {
case platform
case tags
case pairingEnabled = "pairing_enabled"
}
}
}
@@ -0,0 +1,90 @@
public import Foundation
/// The exact local binding and bounded peer set required by one app runtime.
public struct CmxConnectivityDiscoveryScope: Codable, Equatable, Sendable {
/// The caller's own binding, preserved independently from the peer selector.
public let localBinding: LocalBinding
/// The bounded opposite-platform bindings visible to this runtime.
public let peerBindings: PeerBindings
private enum CodingKeys: String, CodingKey {
case localBinding = "local_binding"
case peerBindings = "peer_bindings"
}
/// Creates the canonical scope echoed by connectivity v3.
///
/// Peer tags are lowercased, deduplicated by rejection, and sorted so the
/// case-insensitive compatibility contract is stable across implementations.
public init(
deviceID: String,
appInstanceID: String,
tag: String,
platform: CmxIrohPlatform,
peerPlatform: CmxIrohPlatform,
peerTags: [String]? = nil,
peerPairingEnabled: Bool? = nil
) throws {
guard isCanonicalUUID(deviceID),
isCanonicalUUID(appInstanceID),
isSafeTag(tag),
platform != peerPlatform,
peerTags.map({ (1 ... 8).contains($0.count) }) ?? true,
peerTags?.allSatisfy(isSafeTag) ?? true else {
throw CmxConnectivityDiscoveryScopeError.invalidScope
}
let canonicalPeerTags = peerTags?.map { $0.lowercased() }
guard canonicalPeerTags.map({ Set($0).count == $0.count }) ?? true else {
throw CmxConnectivityDiscoveryScopeError.invalidScope
}
let sortedTags = canonicalPeerTags?.sorted()
localBinding = LocalBinding(
deviceID: deviceID,
appInstanceID: appInstanceID,
tag: tag,
platform: platform
)
peerBindings = PeerBindings(
platform: peerPlatform,
tags: sortedTags,
pairingEnabled: peerPairingEnabled
)
}
/// Decodes and validates a canonical discovery scope.
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let local = try container.decode(LocalBinding.self, forKey: .localBinding)
let peers = try container.decode(PeerBindings.self, forKey: .peerBindings)
try self.init(
deviceID: local.deviceID,
appInstanceID: local.appInstanceID,
tag: local.tag,
platform: local.platform,
peerPlatform: peers.platform,
peerTags: peers.tags,
peerPairingEnabled: peers.pairingEnabled
)
}
}
private func isCanonicalUUID(_ value: String) -> Bool {
guard UUID(uuidString: value)?.uuidString.lowercased() == value,
value.count == 36 else { return false }
let characters = Array(value.utf8)
guard (49 ... 56).contains(characters[14]),
[56, 57, 97, 98].contains(characters[19]) else { return false }
return true
}
private func isSafeTag(_ value: String) -> Bool {
guard (1 ... 64).contains(value.utf8.count) else { return false }
return value.utf8.allSatisfy { byte in
(48 ... 57).contains(byte)
|| (65 ... 90).contains(byte)
|| (97 ... 122).contains(byte)
|| [45, 46, 95].contains(byte)
}
}
@@ -0,0 +1,5 @@
/// Validation failures for connectivity discovery scopes.
public enum CmxConnectivityDiscoveryScopeError: Error, Equatable, Sendable {
/// The scope contains an invalid identity, tag, platform pair, or peer selector.
case invalidScope
}
@@ -1,7 +1,9 @@
/// Versioned response from the authoritative connectivity reconciliation route.
public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
/// The only protocol version accepted by this implementation.
/// The global-snapshot protocol used when scoped discovery is unavailable.
public static let protocolVersion = 2
/// The bounded discovery protocol used by current clients.
public static let scopedProtocolVersion = 3
/// Backend connectivity protocol version.
public let protocolVersion: Int
@@ -22,6 +24,18 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
/// Older servers omit this field, so clients fetch paginated discovery.
public let snapshotComplete: Bool?
/// The bounded projection represented by a connectivity v3 snapshot.
public let discoveryScope: CmxConnectivityDiscoveryScope?
/// True only when the server proves `snapshot` covers the echoed scope.
public let snapshotScopeComplete: Bool?
/// Whether the snapshot carries either global or scoped completeness proof.
public var snapshotIsComplete: Bool {
snapshot != nil
&& (snapshotComplete == true || snapshotScopeComplete == true)
}
private enum CodingKeys: String, CodingKey {
case protocolVersion = "protocol_version"
case revision
@@ -29,6 +43,8 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
case reset
case snapshot
case snapshotComplete = "snapshot_complete"
case discoveryScope = "discovery_scope"
case snapshotScopeComplete = "snapshot_scope_complete"
}
/// Decodes and validates one atomic reconciliation response.
@@ -46,10 +62,27 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
Bool.self,
forKey: .snapshotComplete
)
guard protocolVersion == Self.protocolVersion,
let discoveryScope = try container.decodeIfPresent(
CmxConnectivityDiscoveryScope.self,
forKey: .discoveryScope
)
let snapshotScopeComplete = try container.decodeIfPresent(
Bool.self,
forKey: .snapshotScopeComplete
)
let validCompletenessContract = switch protocolVersion {
case Self.protocolVersion:
discoveryScope == nil && snapshotScopeComplete == nil
case Self.scopedProtocolVersion:
discoveryScope != nil && snapshotComplete == nil
default:
false
}
guard validCompletenessContract,
changed == (snapshot != nil),
!reset || changed,
snapshot != nil || snapshotComplete == nil,
snapshot != nil || snapshotScopeComplete == nil,
(snapshot?.routeContractVersion ?? 1) == 1,
(snapshot?.revision ?? revision) == revision else {
throw DecodingError.dataCorrupted(
@@ -65,6 +98,8 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
self.reset = reset
self.snapshot = snapshot
self.snapshotComplete = snapshotComplete
self.discoveryScope = discoveryScope
self.snapshotScopeComplete = snapshotScopeComplete
}
init(
@@ -78,5 +113,7 @@ public struct CmxConnectivitySyncResponse: Decodable, Equatable, Sendable {
reset = false
snapshot = legacySnapshot
self.snapshotComplete = snapshotComplete
discoveryScope = nil
snapshotScopeComplete = nil
}
}
@@ -304,6 +304,8 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
case relay
case discovery
case discoveryComplete = "discovery_complete"
case discoveryScope = "discovery_scope"
case discoveryScopeComplete = "discovery_scope_complete"
}
/// Monotonic account route revision after this registration commit.
@@ -316,6 +318,18 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
/// True only when the embedded snapshot covers every active binding.
/// Older brokers omit this proof, so clients must fetch paginated discovery.
public let discoveryComplete: Bool?
/// The exact bounded projection represented by embedded discovery.
public let discoveryScope: CmxConnectivityDiscoveryScope?
/// True only when embedded discovery covers every binding in its scope.
public let discoveryScopeComplete: Bool?
/// Whether the embedded discovery is proven complete globally or for its
/// validated scoped-registration request.
public var embeddedDiscoveryComplete: Bool {
discovery != nil
&& (discoveryComplete == true
|| (discoveryScope != nil && discoveryScopeComplete == true))
}
/// Creates a registration response for alternate brokers and tests.
public init(
@@ -323,13 +337,17 @@ public struct CmxIrohRegistrationResponse: Decodable, Equatable, Sendable {
binding: CmxIrohBrokerBinding,
relay: CmxIrohRegistrationRelay,
discovery: CmxIrohDiscoveryResponse? = nil,
discoveryComplete: Bool? = nil
discoveryComplete: Bool? = nil,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
discoveryScopeComplete: Bool? = nil
) {
self.revision = revision
self.binding = binding
self.relay = relay
self.discovery = discovery
self.discoveryComplete = discoveryComplete
self.discoveryScope = discoveryScope
self.discoveryScopeComplete = discoveryScopeComplete
}
}
@@ -132,7 +132,7 @@ extension CmxIrohClientRuntime {
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration?.discovery,
registration?.discoveryComplete == true {
registration?.embeddedDiscoveryComplete == true {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration?.revision,
snapshotRevision >= registrationRevision,
@@ -40,10 +40,49 @@ extension CmxIrohClientRuntime {
throw CmxIrohClientRuntimeError.relayFleetMismatch
}
let revision = lifecycleRevision
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
await relayCoordinator?.deactivate()
relayCoordinator = nil
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
let refreshSchedule = CmxIrohRelayRefreshSchedule(
role: .client,
endpointIdentity: binding.endpointID
)
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
jitter: { now, refreshAfter in
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
},
retrySchedule: .foregroundClient,
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activateManagedPolicy(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
profile: profile,
bootstrap: relayBootstrap
)
} catch {
await coordinator.deactivate()
if relayCoordinator === coordinator {
relayCoordinator = nil
}
throw error
}
} else {
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
}
try requireCurrent(revision)
managedRelayURLs = replacementManagedURLs
@@ -98,32 +137,5 @@ extension CmxIrohClientRuntime {
}
await contextRouter.install(provider)
try requireCurrent(revision)
await relayCoordinator?.deactivate()
relayCoordinator = nil
guard profile.source == .managed,
!profile.allowedRelayURLs.isEmpty else { return }
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
retrySchedule: .foregroundClient,
automaticRefreshEnabled: automaticRelayCredentialRefreshEnabled,
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activate(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
bootstrap: relayBootstrap
)
} catch {
// The verified allowlist is already live; direct paths remain usable
// while the coordinator retries a managed credential refresh.
}
}
}
@@ -133,7 +133,7 @@ extension CmxIrohHostRuntime {
let discovery: CmxIrohDiscoveryResponse
do {
if let embedded = registration.discovery,
registration.discoveryComplete == true {
registration.embeddedDiscoveryComplete {
guard let snapshotRevision = embedded.revision,
let registrationRevision = registration.revision,
snapshotRevision == registrationRevision,
@@ -427,11 +427,24 @@ extension CmxIrohHostRuntime {
binding: CmxIrohBrokerBinding,
now: Date
) -> Date? {
guard let expiry = binding.pathHints.compactMap(\.expiresAt).min(),
expiry > now else { return nil }
let remaining = expiry.timeIntervalSince(now)
let safetyWindow = min(15 * 60, max(30, remaining / 4))
return max(now, expiry.addingTimeInterval(-safetyWindow))
// Clients accept the binding's signed direct ports for private-path
// synthesis only while `lastSeenAt` is younger than this same window.
// Keep that broker lease fresh even when the endpoint has no public
// path hints, otherwise an unchanged Mac silently becomes undialable.
let bindingFreshnessExpiry = CmxIrohISO8601Date
.parse(binding.lastSeenAt)?
.addingTimeInterval(CmxIrohPathHint.maximumPrivateHintTTL)
let expiries = ([bindingFreshnessExpiry] + binding.pathHints.map(\.expiresAt))
.compactMap { $0 }
return expiries.compactMap { expiry -> Date? in
let remaining = expiry.timeIntervalSince(now)
guard remaining > 0 else { return nil }
let safetyWindow = min(15 * 60, max(30, remaining / 4))
let deadline = expiry.addingTimeInterval(-safetyWindow)
// A stale or near-expiry authority cannot safely arm an immediate
// renewal: another unchanged success would otherwise spin.
return deadline > now ? deadline : nil
}.min()
}
func refreshRegistration(revision: UInt64) async {
@@ -42,42 +42,54 @@ extension CmxIrohHostRuntime {
throw CmxIrohHostRuntimeError.relayFleetMismatch
}
let revision = lifecycleRevision
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
relayActivationTask?.cancel()
relayActivationTask = nil
await relayCoordinator?.deactivate()
relayCoordinator = nil
if profile.source == .managed, !profile.allowedRelayURLs.isEmpty {
let refreshSchedule = CmxIrohRelayRefreshSchedule(
role: .host,
endpointIdentity: binding.endpointID
)
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
jitter: { now, refreshAfter in
refreshSchedule.deadline(now: now, refreshAfter: refreshAfter)
},
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activateManagedPolicy(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
profile: profile,
bootstrap: relayBootstrap
)
} catch {
await coordinator.deactivate()
if relayCoordinator === coordinator {
relayCoordinator = nil
}
throw error
}
} else {
try await connectivityEngine.replaceRelayProfile(
profile,
expectedIdentity: binding.endpointID
)
}
try requireCurrent(revision)
managedRelayURLs = replacementManagedURLs
currentEndpointRelayProfile = profile
await admissionController?.updateManagedRelayURLs(replacementManagedURLs)
try requireCurrent(revision)
relayActivationTask?.cancel()
relayActivationTask = nil
await relayCoordinator?.deactivate()
relayCoordinator = nil
guard profile.source == .managed,
!profile.allowedRelayURLs.isEmpty else { return }
let coordinator = CmxIrohRelayCredentialCoordinator(
supervisor: connectivityEngine,
broker: broker,
managedRelayURLs: replacementManagedURLs,
selectedRelayURLs: profile.allowedRelayURLs,
credentialDidInstall: { [handleRelayCredential] response in
await handleRelayCredential(response, binding)
}
)
relayCoordinator = coordinator
do {
try await coordinator.activate(
bindingID: binding.bindingID,
endpointIdentity: binding.endpointID,
bootstrap: relayBootstrap
)
} catch {
// The verified allowlist is already live; direct paths remain usable
// while the coordinator retries a managed credential refresh.
}
}
}
@@ -8,11 +8,30 @@ public struct CmxIrohRegisterRequest: Encodable, Equatable, Sendable {
public let payload: String
/// Base64url Ed25519 signature over the registration transcript.
public let signature: String
/// Optional bounded discovery projection returned with registration.
public let discoveryScope: CmxConnectivityDiscoveryScope?
init(challengeID: String, nonce: String, payload: String, signature: String) {
init(
challengeID: String,
nonce: String,
payload: String,
signature: String,
discoveryScope: CmxConnectivityDiscoveryScope? = nil
) {
challengeId = challengeID
self.nonce = nonce
self.payload = payload
self.signature = signature
self.discoveryScope = discoveryScope
}
func including(discoveryScope: CmxConnectivityDiscoveryScope?) -> Self {
Self(
challengeID: challengeId,
nonce: nonce,
payload: payload,
signature: signature,
discoveryScope: discoveryScope
)
}
}
@@ -92,14 +92,10 @@ public actor CmxIrohRelayCredentialCoordinator {
bootstrap: CmxIrohRelayTokenResponse? = nil,
waitForInitialCredential: Bool = false
) async throws {
lifecycleRevision &+= 1
let revision = lifecycleRevision
refreshTask?.cancel()
inFlightRefresh?.task.cancel()
inFlightRefresh = nil
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
binding = expectedBinding
installedCredential = nil
let (expectedBinding, revision) = beginActivation(
bindingID: bindingID,
endpointIdentity: endpointIdentity
)
if let bootstrap {
do {
@@ -167,6 +163,111 @@ public actor CmxIrohRelayCredentialCoordinator {
}
}
/// Replaces one live managed relay policy and starts credential refresh.
///
/// The coordinator owns the endpoint mutation so a policy bootstrap is
/// installed exactly once. This preserves active QUIC sessions while the
/// endpoint's relay client adopts the replacement credentials.
///
/// - Parameters:
/// - bindingID: The broker binding that owns the endpoint.
/// - endpointIdentity: The pinned endpoint identity being updated.
/// - profile: The complete managed relay profile to install.
/// - bootstrap: Credentials already represented by `profile`, when available.
/// - Throws: A policy mismatch, endpoint mutation failure, or cancellation.
public func activateManagedPolicy(
bindingID: String,
endpointIdentity: CmxIrohPeerIdentity,
profile: CmxIrohEndpointRelayProfile,
bootstrap: CmxIrohRelayTokenResponse?
) async throws {
guard profile.source == .managed,
!selectedRelayURLs.isEmpty,
selectedRelayURLs.isSubset(of: managedRelayURLs),
profile.allowedRelayURLs == selectedRelayURLs else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let bootstrapInstallation: (
response: CmxIrohRelayTokenResponse,
configurations: [CmxIrohRelayConfiguration]
)? = try bootstrap.map { response in
let selectedConfigurations = try validatedSelectedConfigurations(response)
guard profile.managedRelays.count == selectedConfigurations.count,
profile.managedRelays.allSatisfy(selectedConfigurations.contains) else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
return (response, selectedConfigurations)
}
let (expectedBinding, revision) = beginActivation(
bindingID: bindingID,
endpointIdentity: endpointIdentity
)
try await supervisor.replaceRelayProfile(
profile,
expectedIdentity: endpointIdentity
)
try Task.checkCancellation()
guard isCurrent(revision), binding == expectedBinding else {
throw CancellationError()
}
if let bootstrapInstallation {
let installed = try recordInstallation(
bootstrapInstallation.response,
selectedConfigurations: bootstrapInstallation.configurations,
binding: expectedBinding,
revision: revision
)
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
return
}
do {
let response = try await broker.issueRelayToken(
bindingID: bindingID,
endpointID: endpointIdentity
)
let installed = try await install(
response,
binding: expectedBinding,
revision: revision
)
startLoopIfEnabled(revision: revision, firstRefresh: installed.refreshAfter)
} catch {
guard isCurrent(revision), !Task.isCancelled else {
throw CancellationError()
}
let delay = retryDelay(failureCount: 0, error: error)
startLoopIfEnabled(
revision: revision,
firstRefresh: retryDeadline(
now: clock.now(),
backoff: delay,
honorsServerFloor: (error as? any CmxRetryAfterProviding)?
.retryAfterSeconds != nil
),
initialFailureCount: 1
)
}
}
private func beginActivation(
bindingID: String,
endpointIdentity: CmxIrohPeerIdentity
) -> (Binding, UInt64) {
lifecycleRevision &+= 1
let revision = lifecycleRevision
refreshTask?.cancel()
inFlightRefresh?.task.cancel()
inFlightRefresh = nil
let expectedBinding = Binding(id: bindingID, endpointIdentity: endpointIdentity)
binding = expectedBinding
installedCredential = nil
return (expectedBinding, revision)
}
private func installInitialCredentialAfterRetry(
binding: Binding,
revision: UInt64,
@@ -415,27 +516,14 @@ public actor CmxIrohRelayCredentialCoordinator {
guard isCurrent(revision), binding == expectedBinding else {
throw CancellationError()
}
guard response.relayFleet.count == managedRelayURLs.count,
Set(response.relayFleet) == managedRelayURLs else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let now = clock.now()
let configurations = try response.relayConfigurations(now: now)
let selectedConfigurations = configurations.filter {
selectedRelayURLs.contains($0.url)
}
guard !selectedRelayURLs.isEmpty,
selectedConfigurations.count == selectedRelayURLs.count,
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let selectedConfigurations = try validatedSelectedConfigurations(response)
try Task.checkCancellation()
guard isCurrent(revision), binding == expectedBinding else {
throw CancellationError()
}
if selectedRelayURLs == managedRelayURLs {
try await supervisor.replaceRelays(
configurations,
selectedConfigurations,
expectedIdentity: expectedBinding.endpointIdentity
)
} else {
@@ -448,6 +536,39 @@ public actor CmxIrohRelayCredentialCoordinator {
expectedIdentity: expectedBinding.endpointIdentity
)
}
return try recordInstallation(
response,
selectedConfigurations: selectedConfigurations,
binding: expectedBinding,
revision: revision
)
}
private func validatedSelectedConfigurations(
_ response: CmxIrohRelayTokenResponse
) throws -> [CmxIrohRelayConfiguration] {
guard response.relayFleet.count == managedRelayURLs.count,
Set(response.relayFleet) == managedRelayURLs else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
let configurations = try response.relayConfigurations(now: clock.now())
let selectedConfigurations = configurations.filter {
selectedRelayURLs.contains($0.url)
}
guard !selectedRelayURLs.isEmpty,
selectedConfigurations.count == selectedRelayURLs.count,
selectedRelayURLs.isSubset(of: managedRelayURLs) else {
throw CmxIrohRelayCredentialCoordinatorError.relayFleetMismatch
}
return selectedConfigurations
}
private func recordInstallation(
_ response: CmxIrohRelayTokenResponse,
selectedConfigurations: [CmxIrohRelayConfiguration],
binding expectedBinding: Binding,
revision: UInt64
) throws -> InstalledCredential {
try Task.checkCancellation()
guard isCurrent(revision), binding == expectedBinding,
let refreshAfter = selectedConfigurations.map(\.refreshAfter).min(),
@@ -0,0 +1,47 @@
import CMUXMobileCore
import Foundation
/// Assigns endpoint-stable, non-overlapping relay credential refresh slots.
struct CmxIrohRelayRefreshSchedule: Sendable {
enum Role: Sendable {
case host
case client
fileprivate var phaseStart: Int {
switch self {
case .host: 0
case .client: 30
}
}
}
private static let phaseWidth = 15
private static let minuteDuration: TimeInterval = 60
private static let fnvOffsetBasis: UInt64 = 14_695_981_039_346_656_037
private static let fnvPrime: UInt64 = 1_099_511_628_211
private let secondWithinMinute: Int
init(role: Role, endpointIdentity: CmxIrohPeerIdentity) {
var hash = Self.fnvOffsetBasis
for byte in endpointIdentity.endpointID.utf8 {
hash ^= UInt64(byte)
hash &*= Self.fnvPrime
}
secondWithinMinute = role.phaseStart + Int(hash % UInt64(Self.phaseWidth))
}
func deadline(now: Date, refreshAfter: Date) -> Date {
let refreshEpoch = refreshAfter.timeIntervalSince1970
let minuteStart = floor(refreshEpoch / Self.minuteDuration)
* Self.minuteDuration
var candidateEpoch = minuteStart + TimeInterval(secondWithinMinute)
if candidateEpoch > refreshEpoch {
candidateEpoch -= Self.minuteDuration
}
return min(
refreshAfter,
max(now, Date(timeIntervalSince1970: candidateEpoch))
)
}
}
@@ -25,6 +25,20 @@ public struct CmxIrohBrokerCredentials: Sendable, CustomStringConvertible,
public var debugDescription: String { description }
}
private func isUnsupportedRegistrationScope(
_ error: CmxIrohTrustBrokerClientError
) -> Bool {
guard case let .rejected(statusCode, code) = error else { return false }
return statusCode == 400 && code == "unknown_field"
}
private func isMissingScopedDiscoveryRoute(
_ error: CmxIrohTrustBrokerClientError
) -> Bool {
guard case let .rejected(statusCode, _) = error else { return false }
return statusCode == 404
}
/// One authenticated account and credential pair captured atomically.
///
/// Platform auth coordinators map their native session snapshot into this
@@ -174,10 +188,12 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
private struct ConnectivitySyncRequest: Encodable {
let protocolVersion: Int
let knownRevision: UInt64?
let discoveryScope: CmxConnectivityDiscoveryScope?
private enum CodingKeys: String, CodingKey {
case protocolVersion = "protocol_version"
case knownRevision = "known_revision"
case discoveryScope = "discovery_scope"
}
func encode(to encoder: any Encoder) throws {
@@ -186,12 +202,13 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
if let knownRevision {
try container.encode(knownRevision, forKey: .knownRevision)
} else {
// The v2 wire contract distinguishes an initial sync (`null`)
// The wire contract distinguishes an initial sync (`null`)
// from an absent field. Swift's synthesized Optional encoding
// omits nil values, which the bounded server parser correctly
// rejects as an incomplete request.
try container.encodeNil(forKey: .knownRevision)
}
try container.encodeIfPresent(discoveryScope, forKey: .discoveryScope)
}
}
@@ -252,17 +269,20 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
private let transport: any CmxIrohHTTPTransport
private let requestTimeout: TimeInterval
private let backpressureGate: CmxIrohBrokerBackpressureGate?
private let discoveryScope: CmxConnectivityDiscoveryScope?
/// Creates a client that rejects cleartext non-loopback API origins.
public init(
baseURL: URL,
tokenSource: CmxIrohBrokerTokenSource,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
requestTimeout: TimeInterval = 10,
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
) throws {
try self.init(
baseURL: baseURL,
tokenSource: tokenSource,
discoveryScope: discoveryScope,
transport: CmxIrohURLSessionTransport(),
requestTimeout: requestTimeout,
backpressureMode: backpressureMode
@@ -273,6 +293,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
init(
baseURL: URL,
tokenSource: CmxIrohBrokerTokenSource,
discoveryScope: CmxConnectivityDiscoveryScope? = nil,
transport: any CmxIrohHTTPTransport,
requestTimeout: TimeInterval = 10,
backpressureMode: CmxIrohBrokerBackpressureMode = .automatic
@@ -284,6 +305,7 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
self.tokenSource = tokenSource
self.transport = transport
self.requestTimeout = requestTimeout
self.discoveryScope = discoveryScope
switch backpressureMode {
case .automatic:
backpressureGate = CmxIrohBrokerBackpressureGate()
@@ -314,12 +336,9 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
public func register(
_ request: CmxIrohRegisterRequest
) async throws -> CmxIrohRegistrationResponse {
try await send(
path: "api/devices/iroh/register",
method: "POST",
body: request,
operation: .registration
)
try await withBackpressure(operation: .registration) {
try await self.registerUngated(request)
}
}
/// Runs the challenge and signed registration legs without regenerating payload bytes.
@@ -334,33 +353,42 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
body: prepared.challengeRequest
)
let request = try signer.sign(prepared: prepared, challenge: challenge)
return try await self.sendUngated(
path: "api/devices/iroh/register",
method: "POST",
body: request
)
return try await self.registerUngated(request)
}
}
public func discover() async throws -> CmxIrohDiscoveryResponse {
try await withBackpressure(operation: .discovery) {
try await self.discoverAllPages()
if self.discoveryScope != nil {
do {
let response = try await self.syncConnectivityUngated(
knownRevision: nil
)
if let snapshot = response.snapshot,
response.snapshotIsComplete {
return snapshot
}
if response.protocolVersion
== CmxConnectivitySyncResponse.scopedProtocolVersion {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
} catch let error as CmxIrohTrustBrokerClientError
where isMissingScopedDiscoveryRoute(error) {
// Older servers have only paginated global discovery.
}
}
return try await self.discoverAllPages()
}
}
/// Reconciles one completely installed route revision with connectivity v2.
/// Reconciles one completely installed route revision with connectivity v3,
/// falling back to global connectivity v2 on older servers.
public func syncConnectivity(
knownRevision: UInt64?
) async throws -> CmxConnectivitySyncResponse {
try await send(
path: "api/connectivity/v2/sync",
method: "POST",
body: ConnectivitySyncRequest(
protocolVersion: CmxConnectivitySyncResponse.protocolVersion,
knownRevision: knownRevision
),
operation: .discovery
)
try await withBackpressure(operation: .discovery) {
try await self.syncConnectivityUngated(knownRevision: knownRevision)
}
}
public func issuePairGrant(
@@ -472,6 +500,80 @@ public actor CmxIrohTrustBrokerClient: CmxIrohRelayPolicyServing {
}
}
private func registerUngated(
_ request: CmxIrohRegisterRequest
) async throws -> CmxIrohRegistrationResponse {
guard let discoveryScope else {
return try await sendUngated(
path: "api/devices/iroh/register",
method: "POST",
body: request.including(discoveryScope: nil)
)
}
do {
let response: CmxIrohRegistrationResponse = try await sendUngated(
path: "api/devices/iroh/register",
method: "POST",
body: request.including(discoveryScope: discoveryScope)
)
guard response.discovery != nil,
response.discoveryScope == discoveryScope,
response.discoveryScopeComplete == true,
response.discoveryComplete != true else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
return response
} catch let error as CmxIrohTrustBrokerClientError
where isUnsupportedRegistrationScope(error) {
// Registration parsing happens before challenge consumption, so
// retrying the identical signature without the optional field is
// safe against older strict servers.
return try await sendUngated(
path: "api/devices/iroh/register",
method: "POST",
body: request.including(discoveryScope: nil)
)
}
}
private func syncConnectivityUngated(
knownRevision: UInt64?
) async throws -> CmxConnectivitySyncResponse {
if let discoveryScope {
do {
let response: CmxConnectivitySyncResponse = try await sendUngated(
path: "api/connectivity/v3/sync",
method: "POST",
body: ConnectivitySyncRequest(
protocolVersion: CmxConnectivitySyncResponse.scopedProtocolVersion,
knownRevision: knownRevision,
discoveryScope: discoveryScope
)
)
guard response.protocolVersion
== CmxConnectivitySyncResponse.scopedProtocolVersion,
response.discoveryScope == discoveryScope,
!response.changed
|| response.snapshotScopeComplete == true else {
throw CmxIrohTrustBrokerClientError.invalidResponse
}
return response
} catch let error as CmxIrohTrustBrokerClientError
where isMissingScopedDiscoveryRoute(error) {
// Continue with connectivity v2 below.
}
}
return try await sendUngated(
path: "api/connectivity/v2/sync",
method: "POST",
body: ConnectivitySyncRequest(
protocolVersion: CmxConnectivitySyncResponse.protocolVersion,
knownRevision: knownRevision,
discoveryScope: nil
)
)
}
private func send<Response: Decodable & Sendable, Body: Encodable>(
path: String,
method: String,
@@ -1,8 +1,147 @@
import Foundation
import Testing
@testable import CmuxIrohTransport
@Suite
struct CmxIrohCustomRelayRuntimeTests {
@Test
func clientManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
let fixture = try ClientRuntimeTestFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse()
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: fixture.relayResponse(),
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
now: fixture.now
))
let credentialUpdates = await endpoint.observedRelayUpdates().count
- initialCredentialUpdates
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
- initialProfileUpdates
#expect(credentialUpdates + profileUpdates == 1)
#expect(await endpoint.observedCloseCallCount() == 0)
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
await runtime.stop()
}
@Test
func hostManagedPolicyRefreshMutatesEndpointExactlyOnce() async throws {
let fixture = try HostRuntimeFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { Date(timeIntervalSince1970: 1_800_000_000) },
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
let initialCredentialUpdates = await endpoint.observedRelayUpdates().count
let initialProfileUpdates = await endpoint.observedRelayProfileUpdates().count
let response = try ClientRuntimeTestFixture().relayResponse()
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: response,
relayURLs: fixture.managedRelays,
now: Date(timeIntervalSince1970: 1_800_000_000)
))
let credentialUpdates = await endpoint.observedRelayUpdates().count
- initialCredentialUpdates
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
- initialProfileUpdates
#expect(credentialUpdates + profileUpdates == 1)
#expect(await endpoint.observedCloseCallCount() == 0)
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
await runtime.stop()
}
@Test
func clientManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
let fixture = try ClientRuntimeTestFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = try CmxIrohClientRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohClientBroker(
binding: fixture.binding,
discovery: fixture.discovery,
relay: fixture.relayResponse()
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { fixture.now }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
await endpoint.setRelayUpdateShouldFail(true)
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: fixture.relayResponse(),
relayURLs: Set(ClientRuntimeTestFixture.relayURLs),
now: fixture.now
))
}
#expect(await runtime.relayCoordinator == nil)
#expect(await endpoint.observedCloseCallCount() == 0)
await runtime.stop()
}
@Test
func hostManagedPolicyFailureDeactivatesUncommittedCoordinator() async throws {
let fixture = try HostRuntimeFixture()
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
),
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { Date(timeIntervalSince1970: 1_800_000_000) },
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
try await Self.waitForRelayMutation(endpoint)
await endpoint.setRelayUpdateShouldFail(true)
let response = try ClientRuntimeTestFixture().relayResponse()
await #expect(throws: TestIrohTransportError.relayUpdateFailed) {
try await runtime.replaceRelayPolicy(try Self.managedPolicy(
response: response,
relayURLs: fixture.managedRelays,
now: Date(timeIntervalSince1970: 1_800_000_000)
))
}
#expect(await runtime.relayCoordinator == nil)
#expect(await endpoint.observedCloseCallCount() == 0)
await runtime.stop()
}
@Test
func clientOverrideSkipsManagedTokenIssuance() async throws {
let fixture = try ClientRuntimeTestFixture()
@@ -153,4 +292,45 @@ struct CmxIrohCustomRelayRuntimeTests {
#expect(await runtime.snapshot().endpointID == fixture.endpointID)
await runtime.stop()
}
private static func managedPolicy(
response: CmxIrohRelayTokenResponse,
relayURLs: Set<String>,
now: Date
) throws -> CmxIrohEffectiveRelayPolicy {
let profile = try CmxIrohEndpointRelayProfile(
managedRelayURLs: relayURLs,
relays: response.relayConfigurations(now: now)
)
return CmxIrohEffectiveRelayPolicy(
endpointRelayProfile: profile,
managedSnapshot: nil,
managedPolicy: nil,
requestedConfiguration: nil,
effectivePreference: .automatic,
source: .managed,
usedCachedPolicy: false,
preferenceRevision: nil,
relayBootstrap: response
)
}
private static func waitForRelayMutation(_ endpoint: TestIrohEndpoint) async throws {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(1))
while clock.now < deadline {
let credentialUpdates = await endpoint.observedRelayUpdates().count
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
if credentialUpdates + profileUpdates > 0 { return }
await Task.yield()
}
let credentialUpdates = await endpoint.observedRelayUpdates().count
let profileUpdates = await endpoint.observedRelayProfileUpdates().count
let counts = "credential updates: \(credentialUpdates), "
+ "profile updates: \(profileUpdates)"
Issue.record("Timed out waiting for relay mutation (\(counts))")
throw RelayMutationTimeout()
}
}
private struct RelayMutationTimeout: Error {}
@@ -6,6 +6,79 @@ import Testing
@testable import CmuxIrohTransport
extension CmxIrohHostRuntimeTests {
@Test
func emptyPublicHintsRenewRegistrationBeforePrivatePortFreshnessExpires() async throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let fixture = try HostRuntimeFixture(now: now)
let renewalDeadline = try #require(
CmxIrohHostRuntime.registrationRenewalDeadline(
binding: fixture.binding,
now: now
)
)
#expect(
renewalDeadline < now.addingTimeInterval(
CmxIrohPathHint.maximumPrivateHintTTL
)
)
let endpoint = TestIrohEndpoint(identity: fixture.endpointID)
let broker = TestIrohHostBroker(
registrationBinding: fixture.binding,
discovery: fixture.discovery
)
let clock = HostRegistrationRenewalClock(now: now)
let runtime = CmxIrohHostRuntime(
factory: TestIrohEndpointFactory(endpoints: [endpoint]),
broker: broker,
configuration: fixture.configuration,
pendingRevocations: fixture.pendingRevocations(),
now: { clock.now() },
registrationClock: clock,
handleTransport: { session, _ in await session.close() }
)
try await runtime.start()
await clock.waitUntilSleeping()
#expect(clock.observedSleepDeadlines().first == renewalDeadline)
clock.advance(to: renewalDeadline)
await broker.waitForRegistrationCount(2)
#expect(await broker.observedRegistrationCount() == 2)
await runtime.stop()
}
@Test
func stalePrivatePortFreshnessDoesNotScheduleImmediateRenewal() throws {
let bindingTime = Date(timeIntervalSince1970: 1_800_000_000)
let fixture = try HostRuntimeFixture(now: bindingTime)
let staleNow = bindingTime.addingTimeInterval(
CmxIrohPathHint.maximumPrivateHintTTL + 1
)
#expect(CmxIrohHostRuntime.registrationRenewalDeadline(
binding: fixture.binding,
now: staleNow
) == nil)
}
@Test
func nearExpiryPublicHintDoesNotScheduleImmediateRenewal() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let fixture = try HostRuntimeFixture(
now: now,
publicHintLifetime: 10
)
#expect(CmxIrohHostRuntime.registrationRenewalDeadline(
binding: fixture.binding,
now: now
) == now.addingTimeInterval(
CmxIrohPathHint.maximumPrivateHintTTL - 15 * 60
))
}
@Test
func unchangedReachabilityRenewsRegistrationBeforeHintExpiry() async throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
@@ -487,6 +560,9 @@ extension CmxIrohHostRuntimeTests {
#expect(await factory.observedConfigurations().count == 1)
#expect(clock.observedSleepDeadlines() == [
now.addingTimeInterval(600),
now.addingTimeInterval(
CmxIrohPathHint.maximumPrivateHintTTL - 15 * 60
),
])
await runtime.stop()
}
@@ -29,6 +29,7 @@ struct HostRuntimeFixture {
managedRelays = Set(Self.relayURLs)
binding = try Self.binding(
endpointID: endpointID.endpointID,
lastSeenAt: now,
publicHintObservedAt: publicHintLifetime == nil ? nil : now,
publicHintExpiresAt: publicHintLifetime.map(now.addingTimeInterval)
)
@@ -97,6 +98,7 @@ struct HostRuntimeFixture {
static func binding(
endpointID: String,
bindingID: String = "123e4567-e89b-42d3-a456-426614174010",
lastSeenAt: Date = Date(timeIntervalSince1970: 1_800_000_000),
publicHintObservedAt: Date? = nil,
publicHintExpiresAt: Date? = nil
) throws -> CmxIrohBrokerBinding {
@@ -105,6 +107,7 @@ struct HostRuntimeFixture {
from: bindingJSON(
endpointID: endpointID,
bindingID: bindingID,
lastSeenAt: lastSeenAt,
publicHintObservedAt: publicHintObservedAt,
publicHintExpiresAt: publicHintExpiresAt
)
@@ -154,6 +157,7 @@ struct HostRuntimeFixture {
endpointID: String,
bindingID: String = "123e4567-e89b-42d3-a456-426614174010",
deviceID: String = "123e4567-e89b-42d3-a456-426614174011",
lastSeenAt: Date = Date(timeIntervalSince1970: 1_800_000_000),
publicHintObservedAt: Date? = nil,
publicHintExpiresAt: Date? = nil
) throws -> Data {
@@ -182,7 +186,7 @@ struct HostRuntimeFixture {
"pairing_enabled": true,
"capabilities": ["rpc", "multistream"],
"path_hints": pathHints,
"last_seen_at": "2026-07-09T12:00:00.000Z",
"last_seen_at": ISO8601DateFormatter().string(from: lastSeenAt),
])
}
}
@@ -5,6 +5,84 @@ import Testing
@Suite
struct CmxIrohRelayCredentialCoordinatorTests {
@Test
func hostAndClientRefreshSlotsStaySeparatedAcrossCredentialCycles() throws {
let hostIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "1a", count: 32)
)
let clientIdentity = try CmxIrohPeerIdentity(
endpointID: String(repeating: "b7", count: 32)
)
let hostSchedule = CmxIrohRelayRefreshSchedule(
role: .host,
endpointIdentity: hostIdentity
)
let clientSchedule = CmxIrohRelayRefreshSchedule(
role: .client,
endpointIdentity: clientIdentity
)
let now = Date(timeIntervalSince1970: 1_700_000_000)
var hostSeconds: [Int] = []
var clientSeconds: [Int] = []
for cycle in 1 ... 8 {
let refreshAfter = now.addingTimeInterval(TimeInterval(cycle * 240))
let hostDeadline = hostSchedule.deadline(
now: now,
refreshAfter: refreshAfter
)
let clientDeadline = clientSchedule.deadline(
now: now,
refreshAfter: refreshAfter
)
let hostSecond = Int(hostDeadline.timeIntervalSince1970) % 60
let clientSecond = Int(clientDeadline.timeIntervalSince1970) % 60
hostSeconds.append(hostSecond)
clientSeconds.append(clientSecond)
#expect((0 ... 14).contains(hostSecond))
#expect((30 ... 44).contains(clientSecond))
#expect(hostDeadline >= now)
#expect(clientDeadline >= now)
#expect(hostDeadline <= refreshAfter)
#expect(clientDeadline <= refreshAfter)
}
#expect(Set(hostSeconds).count == 1)
#expect(Set(clientSeconds).count == 1)
}
@Test
func refreshSlotsSpreadEndpointsWithinEachRole() throws {
let refreshAfter = Date(timeIntervalSince1970: 1_700_000_240)
let now = refreshAfter.addingTimeInterval(-240)
let hostSlots = try (0 ..< 16).map { index in
let identity = try CmxIrohPeerIdentity(
endpointID: String(format: "%064x", index + 1)
)
return Int(
CmxIrohRelayRefreshSchedule(role: .host, endpointIdentity: identity)
.deadline(now: now, refreshAfter: refreshAfter)
.timeIntervalSince1970
) % 60
}
let clientSlots = try (0 ..< 16).map { index in
let identity = try CmxIrohPeerIdentity(
endpointID: String(format: "%064x", index + 1)
)
return Int(
CmxIrohRelayRefreshSchedule(role: .client, endpointIdentity: identity)
.deadline(now: now, refreshAfter: refreshAfter)
.timeIntervalSince1970
) % 60
}
#expect(Set(hostSlots).count > 1)
#expect(hostSlots.allSatisfy { (0 ... 14).contains($0) })
#expect(Set(clientSlots).count > 1)
#expect(clientSlots.allSatisfy { (30 ... 44).contains($0) })
}
@Test
func bootstrapInstallsCompleteFleetBeforeSleepingUntilRefresh() async throws {
let fixture = try RelayCoordinatorFixture()
@@ -5,6 +5,21 @@ import Testing
@Suite(.serialized)
struct CmxIrohTrustBrokerClientTests {
@Test
func discoveryScopeNormalizesOnlyPeerTags() throws {
let scope = try CmxConnectivityDiscoveryScope(
deviceID: "123e4567-e89b-42d3-a456-426614174001",
appInstanceID: "123e4567-e89b-42d3-a456-426614174002",
tag: "LocalFeatureA",
platform: .ios,
peerPlatform: .mac,
peerTags: ["FeatureA"]
)
#expect(scope.localBinding.tag == "LocalFeatureA")
#expect(scope.peerBindings.tags == ["featurea"])
}
@Test
func challengeUsesNativeStackHeadersAndExactJSON() async throws {
let transport = RecordingBrokerTransport(responses: [
@@ -87,6 +102,108 @@ struct CmxIrohTrustBrokerClientTests {
#expect(response.discoveryComplete == true)
}
@Test
func scopedRegistrationFallsBackWithoutRegeneratingSignedPayload() async throws {
let transport = RecordingBrokerTransport(responses: [
.json(
status: 201,
body: #"{"challenge_id":"123e4567-e89b-42d3-a456-426614174000","nonce":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","expires_at":"2026-07-10T01:00:00.000Z"}"#
),
.json(status: 400, body: #"{"error":"unknown_field"}"#),
.json(status: 201, body: Self.registrationResponse),
])
let client = try makeClient(
transport: transport,
discoveryScope: iosDiscoveryScope()
)
let signer = try registrationSigner()
let prepared = try signer.prepare(payload: registrationPayload())
_ = try await client.register(prepared: prepared, signer: signer)
let requests = await transport.requests()
#expect(requests.compactMap { $0.url?.path } == [
"/api/devices/iroh/challenge",
"/api/devices/iroh/register",
"/api/devices/iroh/register",
])
let scopedBody = try #require(requests[1].httpBody)
let fallbackBody = try #require(requests[2].httpBody)
var scopedObject = try #require(
JSONSerialization.jsonObject(with: scopedBody) as? [String: Any]
)
let fallbackObject = try #require(
JSONSerialization.jsonObject(with: fallbackBody) as? [String: Any]
)
#expect(scopedObject.removeValue(forKey: "discoveryScope") != nil)
#expect(scopedObject as NSDictionary == fallbackObject as NSDictionary)
}
@Test
func scopedRegistrationAcceptsOnlyItsEchoedCompleteProjection() async throws {
let scope = try iosDiscoveryScope()
var responseObject = try #require(
JSONSerialization.jsonObject(
with: Data(Self.registrationResponse.utf8)
) as? [String: Any]
)
responseObject["revision"] = 7
responseObject["discovery"] = try Self.discoveryObject(revision: 7)
responseObject["discovery_complete"] = false
responseObject["discovery_scope"] = try scopeObject(scope)
responseObject["discovery_scope_complete"] = true
let transport = RecordingBrokerTransport(responses: [
.json(status: 201, body: try Self.jsonString(responseObject)),
])
let client = try makeClient(
transport: transport,
discoveryScope: scope
)
let response = try await client.register(
CmxIrohRegisterRequest(
challengeID: "123e4567-e89b-42d3-a456-426614174000",
nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
payload: "e30",
signature: String(repeating: "A", count: 86)
)
)
#expect(response.discoveryScope == scope)
#expect(response.discoveryScopeComplete == true)
#expect(response.embeddedDiscoveryComplete)
}
@Test
func scopedRegistrationCompletenessRequiresAnEchoedScope() async throws {
var responseObject = try #require(
JSONSerialization.jsonObject(
with: Data(Self.registrationResponse.utf8)
) as? [String: Any]
)
responseObject["revision"] = 7
responseObject["discovery"] = try Self.discoveryObject(revision: 7)
responseObject["discovery_complete"] = false
responseObject["discovery_scope_complete"] = true
let transport = RecordingBrokerTransport(responses: [
.json(status: 201, body: try Self.jsonString(responseObject)),
])
let client = try makeClient(transport: transport)
let response = try await client.register(
CmxIrohRegisterRequest(
challengeID: "123e4567-e89b-42d3-a456-426614174000",
nonce: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
payload: "e30",
signature: String(repeating: "A", count: 86)
)
)
#expect(response.discoveryScope == nil)
#expect(response.discoveryScopeComplete == true)
#expect(!response.embeddedDiscoveryComplete)
}
@Test
func issuedRegistrationBuildsTheExactManagedRelayFleet() async throws {
let transport = RecordingBrokerTransport(responses: [
@@ -716,6 +833,143 @@ struct CmxIrohTrustBrokerClientTests {
#expect(object["known_revision"] is NSNull)
}
@Test
func connectivityV3SendsAndAcceptsOnlyTheEchoedScope() async throws {
let scope = try iosDiscoveryScope()
let snapshot = try Self.discoveryObject(revision: 2)
let responseBody = try Self.jsonString([
"protocol_version": 3,
"revision": 2,
"changed": true,
"reset": false,
"discovery_scope": try scopeObject(scope),
"snapshot": snapshot,
"snapshot_scope_complete": true,
])
let transport = RecordingBrokerTransport(responses: [
.json(status: 200, body: responseBody),
])
let client = try makeClient(
transport: transport,
discoveryScope: scope
)
let response = try await client.syncConnectivity(knownRevision: nil)
#expect(response.protocolVersion == 3)
#expect(response.discoveryScope == scope)
#expect(response.snapshotIsComplete)
let request = try #require(await transport.requests().first)
#expect(request.url?.path == "/api/connectivity/v3/sync")
let body = try #require(request.httpBody)
let object = try #require(
JSONSerialization.jsonObject(with: body) as? [String: Any]
)
#expect(object["protocol_version"] as? Int == 3)
#expect(object["discovery_scope"] != nil)
}
@Test(arguments: [Bool?.none, false])
func connectivityV3RejectsChangedSnapshotWithoutScopedCompleteness(
completeness: Bool?
) async throws {
let scope = try iosDiscoveryScope()
let snapshot = try Self.discoveryObject(revision: 2)
var responseObject: [String: Any] = [
"protocol_version": 3,
"revision": 2,
"changed": true,
"reset": false,
"discovery_scope": try scopeObject(scope),
"snapshot": snapshot,
]
if let completeness {
responseObject["snapshot_scope_complete"] = completeness
}
let transport = RecordingBrokerTransport(responses: [
.json(
status: 200,
body: try Self.jsonString(responseObject)
),
])
let client = try makeClient(
transport: transport,
discoveryScope: scope
)
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
_ = try await client.syncConnectivity(knownRevision: nil)
}
#expect(await transport.requests().compactMap { $0.url?.path } == [
"/api/connectivity/v3/sync",
])
}
@Test
func scopedDiscoveryRejectsIncompleteV3WithoutFetchingGlobalBindings() async throws {
let scope = try iosDiscoveryScope()
let snapshot = try Self.discoveryObject(revision: 2)
let responseBody = try Self.jsonString([
"protocol_version": 3,
"revision": 2,
"changed": true,
"reset": false,
"discovery_scope": try scopeObject(scope),
"snapshot": snapshot,
"snapshot_scope_complete": false,
])
let transport = RecordingBrokerTransport(responses: [
.json(status: 200, body: responseBody),
])
let client = try makeClient(
transport: transport,
discoveryScope: scope
)
await #expect(throws: CmxIrohTrustBrokerClientError.invalidResponse) {
_ = try await client.discover()
}
#expect(await transport.requests().compactMap { $0.url?.path } == [
"/api/connectivity/v3/sync",
])
}
@Test
func connectivityV3FallsBackToGlobalV2OnOlderServers() async throws {
let snapshot = try Self.discoveryObject(revision: 2)
let v2Response = try Self.jsonString([
"protocol_version": 2,
"revision": 2,
"changed": true,
"reset": false,
"snapshot": snapshot,
"snapshot_complete": true,
])
let transport = RecordingBrokerTransport(responses: [
.json(status: 404, body: #"{"error":"not_found"}"#),
.json(status: 200, body: v2Response),
])
let client = try makeClient(
transport: transport,
discoveryScope: iosDiscoveryScope()
)
let response = try await client.syncConnectivity(knownRevision: nil)
#expect(response.protocolVersion == 2)
#expect(response.snapshotComplete == true)
let requests = await transport.requests()
#expect(requests.compactMap { $0.url?.path } == [
"/api/connectivity/v3/sync",
"/api/connectivity/v2/sync",
])
let v2Body = try #require(requests[1].httpBody)
let v2Object = try #require(
JSONSerialization.jsonObject(with: v2Body) as? [String: Any]
)
#expect(v2Object["discovery_scope"] == nil)
}
@Test
func connectivitySyncRequiresAnAtomicSnapshotAtTheEnvelopeRevision() async throws {
let snapshot = try Self.discoveryObject(revision: 42)
@@ -772,15 +1026,39 @@ struct CmxIrohTrustBrokerClientTests {
}
private func makeClient(
transport: RecordingBrokerTransport
transport: RecordingBrokerTransport,
discoveryScope: CmxConnectivityDiscoveryScope? = nil
) throws -> CmxIrohTrustBrokerClient {
try CmxIrohTrustBrokerClient(
baseURL: #require(URL(string: "https://cmux.example")),
tokenSource: Self.tokenSource,
discoveryScope: discoveryScope,
transport: transport
)
}
private func iosDiscoveryScope() throws -> CmxConnectivityDiscoveryScope {
try CmxConnectivityDiscoveryScope(
deviceID: "123e4567-e89b-42d3-a456-426614174001",
appInstanceID: "123e4567-e89b-42d3-a456-426614174002",
tag: "stable",
platform: .ios,
peerPlatform: .mac,
peerTags: ["nightly", "default"],
peerPairingEnabled: true
)
}
private func scopeObject(
_ scope: CmxConnectivityDiscoveryScope
) throws -> [String: Any] {
try #require(
JSONSerialization.jsonObject(
with: JSONEncoder().encode(scope)
) as? [String: Any]
)
}
private func registrationSigner() throws -> CmxIrohRegistrationSigner {
let secret = try CmxIrohSecretKey(bytes: Data((0 ..< 32).map(UInt8.init)))
let material = try CmxIrohIdentityMaterial(
@@ -26,6 +26,7 @@ final class BrowserStreamContentView: UIView, UIScrollViewDelegate, UIGestureRec
private var panStartOffset = CGPoint.zero
private var displayLink: CADisplayLink?
private var viewportPolicy = BrowserStreamViewportEmissionPolicy()
private var tapClickCounter = BrowserStreamTapClickCounter()
private lazy var scrollMechanicsView: UIScrollView = {
let view = UIScrollView()
@@ -68,12 +69,12 @@ final class BrowserStreamContentView: UIView, UIScrollViewDelegate, UIGestureRec
addSubview(scrollMechanicsView)
addSubview(inputProxy)
// One tap recognizer, forwarded immediately with a rising click count
// (see BrowserStreamTapClickCounter): double tap means Mac double
// click, never local zoom, and single clicks never wait on a
// double-tap recognizer to fail. Pinch owns zooming.
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
tap.require(toFail: doubleTap)
addGestureRecognizer(tap)
addGestureRecognizer(doubleTap)
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
addGestureRecognizer(pinch)
@@ -260,34 +261,19 @@ final class BrowserStreamContentView: UIView, UIScrollViewDelegate, UIGestureRec
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
guard let point = currentTransform.pagePoint(fromViewPoint: gesture.location(in: self)) else { return }
let viewPoint = gesture.location(in: self)
guard let point = currentTransform.pagePoint(fromViewPoint: viewPoint) else { return }
let input = MobileBrowserPointerInput(
panelID: panelID,
kind: .click,
x: Double(point.x),
y: Double(point.y),
clickCount: 1,
clickCount: tapClickCounter.register(at: viewPoint, time: CACurrentMediaTime()),
button: .left
)
delegate?.browserStreamContentView(self, didProducePointer: input)
}
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if zoomScale > 1.001 {
zoomScale = 1
viewportOffset = .zero
} else {
zoomScale = 2
let location = gesture.location(in: self)
viewportOffset = CGPoint(
x: (location.x - bounds.midX) * (zoomScale - 1),
y: (location.y - bounds.midY) * (zoomScale - 1)
)
}
updateGestureModes()
layoutImageLayer()
}
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
if gesture.state == .began { pinchStartScale = zoomScale }
zoomScale = min(max(1, pinchStartScale * gesture.scale), 4)
@@ -12,6 +12,9 @@ public protocol BrowserStreamEventReceiving: AnyObject {
/// Marks a stream active after the Mac accepts `stream.start`.
/// - Parameter descriptor: The descriptor returned by the start request.
func browserStreamDidStart(_ descriptor: MobileBrowserPanelDescriptor)
/// Registers a panel the Mac just created on the phone's behalf.
/// - Parameter descriptor: The descriptor returned by the create request.
func browserPanelCreated(_ descriptor: MobileBrowserPanelDescriptor)
/// Resets subscription-local sequencing immediately before `stream.start`.
/// - Parameter panelID: The Mac browser panel identifier.
func browserStreamWillStart(panelID: String) async
@@ -179,6 +179,8 @@ public struct BrowserStreamPane: View {
symbol: "pause.circle"
)
.accessibilityIdentifier("BrowserStreamPausedOverlay")
} else if state.isBlankPage {
newPagePlaceholder
} else if state.latestFrame == nil {
statusOverlay(
title: L10n.string("mobile.browserStream.waiting", defaultValue: "Waiting for Browser"),
@@ -189,6 +191,33 @@ public struct BrowserStreamPane: View {
}
}
/// Deliberate empty state for a browser that has not opened a page yet.
///
/// A fresh pane's mirror is an empty white capture, which looks like a
/// glitch; this opaque placeholder replaces it until the first navigation.
private var newPagePlaceholder: some View {
ZStack {
Color(red: 0.055, green: 0.063, blue: 0.075)
VStack(spacing: 12) {
Image(systemName: "globe")
.font(.system(size: 36))
.foregroundStyle(.secondary)
Text(L10n.string("mobile.browserStream.newPage", defaultValue: "New Browser"))
.font(.headline)
Text(L10n.string(
"mobile.browserStream.newPageDetail",
defaultValue: "Search or enter an address in the bar below."
))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.foregroundStyle(.white)
.padding(28)
}
.accessibilityIdentifier("BrowserStreamNewPagePlaceholder")
}
private var disconnectedOverlay: some View {
ZStack {
Color.black.opacity(0.82).ignoresSafeArea()
@@ -184,16 +184,17 @@ public final class BrowserStreamStore: BrowserStreamEventReceiving {
replacePanels(in: workspaceID, with: descriptors)
}
/// Registers a panel the Mac just created on the phone's behalf, so it can
/// be activated and streamed before any discovery refresh lands.
/// - Parameter descriptor: The descriptor returned by the create request.
public func browserPanelCreated(_ descriptor: MobileBrowserPanelDescriptor) {
upsertPanel(descriptor)
}
/// Reconciles the descriptor returned by a successful start request.
/// - Parameter descriptor: The descriptor accepted by the Mac.
public func browserStreamDidStart(_ descriptor: MobileBrowserPanelDescriptor) {
var descriptors = panels(in: descriptor.workspaceID)
if let index = descriptors.firstIndex(where: { $0.panelID == descriptor.panelID }) {
descriptors[index] = descriptor
} else {
descriptors.append(descriptor)
}
replacePanels(in: descriptor.workspaceID, with: descriptors)
upsertPanel(descriptor)
guard let state = statesByPanel[descriptor.panelID] else { return }
state.connectionStatus = .connected
if state.streamStatus != .streaming {
@@ -315,6 +316,16 @@ public final class BrowserStreamStore: BrowserStreamEventReceiving {
return event.panelID
}
private func upsertPanel(_ descriptor: MobileBrowserPanelDescriptor) {
var descriptors = panels(in: descriptor.workspaceID)
if let index = descriptors.firstIndex(where: { $0.panelID == descriptor.panelID }) {
descriptors[index] = descriptor
} else {
descriptors.append(descriptor)
}
replacePanels(in: descriptor.workspaceID, with: descriptors)
}
private func installDialog(_ dialog: MobileBrowserDialogEvent) {
guard lastResolvedDialogIDByPanel[dialog.panelID] != dialog.dialogID else { return }
lastResolvedDialogIDByPanel[dialog.panelID] = nil
@@ -160,6 +160,16 @@ public final class BrowserStreamSurfaceState: Identifiable {
/// Whether the hidden input proxy should hold first responder.
public var shouldFocusInput: Bool { keyboardPolicy.shouldFocusInput }
/// Whether the panel has never opened a page (a fresh New Browser pane).
///
/// A blank pane mirrors an empty white surface, which reads as a rendering
/// glitch; the pane shows a purposeful new-page placeholder instead until
/// the first navigation gives the panel a URL.
public var isBlankPage: Bool {
let trimmed = url?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty || trimmed == "about:blank"
}
/// Prepares sequence and status state for a new Mac stream subscription.
public func prepareForStreamStart() {
newestDisplayedSequence = nil
@@ -0,0 +1,48 @@
import CoreGraphics
import Foundation
/// Computes Mac-style click counts from successive phone taps.
///
/// The mirror preserves Mac pointer semantics: a double tap is a double click
/// (word selection), a triple tap a triple click (paragraph selection). Taps
/// are forwarded immediately with a rising click count, exactly like a
/// physical mouse, so single clicks never wait on a double-tap recognizer to
/// fail. Zooming belongs to the pinch gesture alone.
struct BrowserStreamTapClickCounter {
/// Maximum seconds between taps that still chain the click count.
let chainInterval: TimeInterval
/// Maximum view-point distance between taps that still chain the count.
let chainRadius: CGFloat
private var lastTime: TimeInterval?
private var lastLocation: CGPoint?
private var count = 0
/// Creates a counter with Mac-like double-click chaining thresholds.
/// - Parameters:
/// - chainInterval: Seconds within which a tap continues the chain.
/// - chainRadius: View points within which a tap continues the chain.
init(chainInterval: TimeInterval = 0.45, chainRadius: CGFloat = 28) {
self.chainInterval = chainInterval
self.chainRadius = chainRadius
}
/// Registers one tap and returns the click count to forward to the Mac.
/// - Parameters:
/// - location: The tap location in view points.
/// - time: A monotonic timestamp for the tap.
/// - Returns: The Mac click count for this tap (1 for a lone tap, 2 for a
/// double click, and so on).
mutating func register(at location: CGPoint, time: TimeInterval) -> Int {
if let lastTime, let lastLocation,
time - lastTime <= chainInterval,
hypot(location.x - lastLocation.x, location.y - lastLocation.y) <= chainRadius {
count += 1
} else {
count = 1
}
lastTime = time
lastLocation = location
return count
}
}
@@ -0,0 +1,55 @@
import CMUXMobileCore
import Foundation
import Testing
@testable import CmuxMobileBrowserStream
/// Coverage for phone-initiated panel creation: the New Browser button relies
/// on the created descriptor being activatable before any discovery refresh.
@MainActor
struct BrowserStreamStorePanelCreationTests {
private func descriptor(
panelID: String,
workspaceID: String,
title: String? = nil
) -> MobileBrowserPanelDescriptor {
MobileBrowserPanelDescriptor(
panelID: panelID,
workspaceID: workspaceID,
url: nil,
title: title,
pageWidth: 800,
pageHeight: 600,
canGoBack: false,
canGoForward: false,
isLoading: false
)
}
@Test func createdPanelIsImmediatelyActivatable() {
let store = BrowserStreamStore()
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1"))
#expect(store.panels(in: "ws-1").map(\.panelID) == ["panel-new"])
let state = store.activate(panelID: "panel-new", in: "ws-1")
#expect(state != nil)
#expect(store.activeState(in: "ws-1")?.id == "panel-new")
}
@Test func repeatedCreationUpdatesInsteadOfDuplicating() {
let store = BrowserStreamStore()
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1"))
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1", title: "Example"))
let panels = store.panels(in: "ws-1")
#expect(panels.count == 1)
#expect(panels.first?.title == "Example")
}
@Test func createdPanelJoinsExistingDiscovery() {
let store = BrowserStreamStore()
store.replacePanels(in: "ws-1", with: [descriptor(panelID: "panel-old", workspaceID: "ws-1")])
store.browserPanelCreated(descriptor(panelID: "panel-new", workspaceID: "ws-1"))
#expect(store.panels(in: "ws-1").map(\.panelID) == ["panel-old", "panel-new"])
}
}
@@ -37,6 +37,46 @@ import Testing
#expect(state.streamStatus == .streaming)
}
@Test @MainActor func blankPageTracksNavigationState() {
let descriptor = MobileBrowserPanelDescriptor(
panelID: "panel-new",
workspaceID: "workspace-1",
url: nil,
title: nil,
pageWidth: 400,
pageHeight: 300,
canGoBack: false,
canGoForward: false,
isLoading: false
)
let state = BrowserStreamSurfaceState(descriptor: descriptor)
#expect(state.isBlankPage)
state.apply(MobileBrowserStateEvent(
panelID: "panel-new",
url: "about:blank",
title: nil,
canGoBack: false,
canGoForward: false,
isLoading: false,
progress: 1,
editableFocused: false
))
#expect(state.isBlankPage)
state.apply(MobileBrowserStateEvent(
panelID: "panel-new",
url: "https://example.com",
title: "Example",
canGoBack: true,
canGoForward: false,
isLoading: false,
progress: 1,
editableFocused: false
))
#expect(!state.isBlankPage)
}
private func makeImage() -> CGImage? {
CGContext(
data: nil,
@@ -0,0 +1,40 @@
import CoreGraphics
import Testing
@testable import CmuxMobileBrowserStream
/// Mac click-count semantics for phone taps: a double tap must reach the Mac
/// as a double click (word selection), never as a local zoom gesture.
struct BrowserStreamTapClickCounterTests {
@Test func quickSecondTapBecomesDoubleClick() {
var counter = BrowserStreamTapClickCounter()
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.0) == 1)
#expect(counter.register(at: CGPoint(x: 104, y: 98), time: 10.3) == 2)
}
@Test func thirdTapBecomesTripleClick() {
var counter = BrowserStreamTapClickCounter()
#expect(counter.register(at: CGPoint(x: 50, y: 50), time: 1.0) == 1)
#expect(counter.register(at: CGPoint(x: 50, y: 50), time: 1.3) == 2)
#expect(counter.register(at: CGPoint(x: 50, y: 50), time: 1.6) == 3)
}
@Test func slowSecondTapRestartsAtSingleClick() {
var counter = BrowserStreamTapClickCounter()
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.0) == 1)
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.6) == 1)
}
@Test func distantSecondTapRestartsAtSingleClick() {
var counter = BrowserStreamTapClickCounter()
#expect(counter.register(at: CGPoint(x: 100, y: 100), time: 10.0) == 1)
#expect(counter.register(at: CGPoint(x: 180, y: 100), time: 10.2) == 1)
}
@Test func chainRestartsAfterBreak() {
var counter = BrowserStreamTapClickCounter()
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 1.0) == 1)
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 1.2) == 2)
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 5.0) == 1)
#expect(counter.register(at: CGPoint(x: 10, y: 10), time: 5.2) == 2)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Typed parameters for `mobile.browser.create`.
struct MobileBrowserCreateParameters: Encodable, Sendable {
/// The Mac-local workspace identifier.
let workspaceID: String
/// Creates browser-create parameters.
init(workspaceID: String) { self.workspaceID = workspaceID }
private enum CodingKeys: String, CodingKey { case workspaceID = "workspace_id" }
}
@@ -14,6 +14,18 @@ extension MobileCoreRPCClient {
return try MobileBrowserListResponse.decode(data).panels
}
/// Creates a new browser panel in one workspace for immediate streaming.
/// - Parameter workspaceID: The Mac-local workspace identifier.
/// - Returns: The descriptor of the freshly created panel.
/// - Throws: A transport, authorization, RPC, or response-decoding error.
public func createMobileBrowserPanel(workspaceID: String) async throws -> MobileBrowserPanelDescriptor {
let data = try await sendBrowserRequest(
method: "mobile.browser.create",
parameters: MobileBrowserCreateParameters(workspaceID: workspaceID)
)
return try JSONDecoder().decode(MobileBrowserPanelDescriptor.self, from: data)
}
/// Starts streaming one browser panel and returns its descriptor.
/// - Parameters:
/// - panelID: The Mac browser panel identifier.
@@ -723,7 +723,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
// token so legacy pairings cannot accidentally narrow the global
// feed; Stack auth is still attached to every TCP request.
return true
case "mobile.browser.list":
case "mobile.browser.list", "mobile.browser.create":
return !ticketCoverage.ticketCoversWorkspaceRequest(
ticket: ticket,
workspaceSelection: workspaceSelection.value
@@ -0,0 +1,110 @@
import Foundation
/// The authenticated Mac-side gate for forwarding notifications to this phone.
///
/// The Mac includes this only after the caller proves same-account ownership.
/// Missing or unknown values must therefore be treated as unavailable, never as
/// ready.
public struct MobileHostPhonePushStatus: Decodable, Equatable, Sendable {
/// When the Mac forwards otherwise-qualifying notifications.
public enum Mode: String, Decodable, Equatable, Sendable {
/// Forward only while the Mac is locked, asleep, or idle.
case onlyWhenAway
/// Forward regardless of Mac presence.
case always
}
/// What the authenticated status exchange proves about account ownership.
public enum AccountScope: String, Decodable, Equatable, Sendable {
/// The Mac verified the phone's Stack token against its own account.
case verifiedSameAccount = "verified_same_account"
}
/// The Mac's sanitized current decision for a would-be notification.
public enum Admission: String, Decodable, Equatable, Sendable {
case allowed
case forwardingDisabled = "forwarding_disabled"
case suppressedMacActive = "suppressed_mac_active"
case unknown
}
/// Durability of the Mac's bounded retry queue. Failures degrade restart
/// reliability without claiming that the live APNs request path is down.
public enum QueuePersistence: String, Decodable, Equatable, Sendable {
case unknown
case healthy
case loadFailed = "load_failed"
case saveFailed = "save_failed"
case clearFailed = "clear_failed"
}
/// Whether the Mac's independent forwarding privacy gate is enabled.
public let forwardingEnabled: Bool
/// The Mac's live forwarding mode.
public let mode: Mode
/// Whether the current mode and presence admit a forward right now.
public let admission: Admission
/// Sanitized persistence health for queued Mac-to-phone events.
public let queuePersistence: QueuePersistence
/// Whether terminal title/body content is redacted before upload.
public let hideContent: Bool
/// The API base URL the Mac will send the notification through.
public let apiOrigin: String
/// The account relationship proven by the authenticated RPC.
public let accountScope: AccountScope
/// Creates an authenticated Mac push-status value.
public init(
forwardingEnabled: Bool,
mode: Mode,
admission: Admission = .unknown,
queuePersistence: QueuePersistence = .unknown,
hideContent: Bool = false,
apiOrigin: String,
accountScope: AccountScope
) {
self.forwardingEnabled = forwardingEnabled
self.mode = mode
self.admission = admission
self.queuePersistence = queuePersistence
self.hideContent = hideContent
self.apiOrigin = apiOrigin
self.accountScope = accountScope
}
private enum CodingKeys: String, CodingKey {
case forwardingEnabled = "forwarding_enabled"
case mode
case admission
case queuePersistence = "queue_persistence"
case hideContent = "hide_content"
case apiOrigin = "api_origin"
case accountScope = "account_scope"
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
forwardingEnabled = try container.decode(
Bool.self,
forKey: .forwardingEnabled
)
mode = try container.decode(Mode.self, forKey: .mode)
admission = try container.decodeIfPresent(
Admission.self,
forKey: .admission
) ?? .unknown
queuePersistence = try container.decodeIfPresent(
QueuePersistence.self,
forKey: .queuePersistence
) ?? .unknown
hideContent = try container.decodeIfPresent(
Bool.self,
forKey: .hideContent
) ?? false
apiOrigin = try container.decode(String.self, forKey: .apiOrigin)
accountScope = try container.decode(
AccountScope.self,
forKey: .accountScope
)
}
}
@@ -38,6 +38,10 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
/// colors. `nil` from older Macs that predate the field, in which case the
/// phone keeps its built-in Monokai default.
public let theme: TerminalTheme?
/// Authenticated Mac-side phone-forwarding status. `nil` means the caller
/// could not prove same-account ownership, the Mac predates this field, or
/// the value was malformed. None of those states is ready.
public let phonePush: MobileHostPhonePushStatus?
private enum CodingKeys: String, CodingKey {
case capabilities
@@ -49,6 +53,7 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
case macAppVersion = "mac_app_version"
case macAppBuild = "mac_app_build"
case theme
case phonePush = "phone_push"
}
public init(from decoder: any Decoder) throws {
@@ -69,6 +74,12 @@ public struct MobileHostStatusResponse: Decodable, Sendable {
// leniently: a bad theme object yields `nil` and the phone keeps its
// built-in Monokai default, exactly like an older Mac that omits it.
theme = (try? container.decodeIfPresent(TerminalTheme.self, forKey: .theme)) ?? nil
// Keep an unknown future mode/account value from invalidating the
// transport and identity fields in the same status response.
phonePush = (try? container.decodeIfPresent(
MobileHostPhonePushStatus.self,
forKey: .phonePush
)) ?? nil
}
/// Decode a host-status response from raw JSON data.
@@ -105,6 +105,16 @@ import Testing
#expect(frame.stackAccessToken == "test-stack-token")
}
@Test func browserCreateCarriesMatchingWorkspaceTicketContext() async throws {
let frame = try await recordedRequest(
method: "mobile.browser.create",
params: ["workspace_id": "workspace-main"],
ticketWorkspaceID: "workspace-main"
)
#expect(frame.attachToken == "ticket-secret")
#expect(frame.stackAccessToken == "test-stack-token")
}
@Test func panelCommandUsesMacWideTicketContext() async throws {
let frame = try await recordedRequest(
method: "mobile.browser.stream.start",
@@ -49,6 +49,57 @@ import Testing
#expect(response.terminalThemeRevisionEpoch == "boot-one")
}
@Test func hostStatusDecodesAuthenticatedPhonePushReadiness() throws {
let response = try MobileHostStatusResponse.decode(Data(
"""
{
"mac_device_id": "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE",
"phone_push": {
"forwarding_enabled": true,
"mode": "onlyWhenAway",
"admission": "suppressed_mac_active",
"queue_persistence": "healthy",
"hide_content": true,
"api_origin": "https://cmux-staging.vercel.app",
"account_scope": "verified_same_account"
}
}
""".utf8
))
#expect(response.phonePush == MobileHostPhonePushStatus(
forwardingEnabled: true,
mode: .onlyWhenAway,
admission: .suppressedMacActive,
queuePersistence: .healthy,
hideContent: true,
apiOrigin: "https://cmux-staging.vercel.app",
accountScope: .verifiedSameAccount
))
}
@Test func hostStatusKeepsMissingQueueHealthDistinctFromFailure() throws {
let missing = try MobileHostStatusResponse.decode(Data(
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
))
let failed = try MobileHostStatusResponse.decode(Data(
#"{"phone_push":{"forwarding_enabled":true,"mode":"always","admission":"allowed","queue_persistence":"save_failed","api_origin":"https://cmux.com","account_scope":"verified_same_account"}}"#.utf8
))
#expect(missing.phonePush?.queuePersistence == .unknown)
#expect(failed.phonePush?.queuePersistence == .saveFailed)
}
@Test func hostStatusTreatsMissingOrUnknownPhonePushStateAsUnavailable() throws {
let missing = try MobileHostStatusResponse.decode(Data("{}".utf8))
let unknown = try MobileHostStatusResponse.decode(Data(
#"{"phone_push":{"forwarding_enabled":true,"mode":"future","api_origin":"x","account_scope":"future"}}"#.utf8
))
#expect(missing.phonePush == nil)
#expect(unknown.phonePush == nil)
}
@Test func hostStatusCanonicalizesOnlyUUIDDeviceIDs() throws {
let uppercaseUUID = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"
let uuidResponse = try MobileHostStatusResponse.decode(Data(
@@ -0,0 +1,14 @@
/// The furthest stage a user-triggered test alert has confirmed.
///
/// `queuedOnMac` deliberately does not claim APNs acceptance or visible iOS
/// presentation. Those later stages remain observable through correlated Mac,
/// backend, and device evidence rather than a false-success button label.
public enum MobilePhonePushTestStage: String, Equatable, Sendable {
case queuedOnMac = "queued"
case forwardingDisabled = "forwarding_disabled"
case macActive = "suppressed_mac_active"
case authenticationUnavailable = "authentication_unavailable"
case encodingFailed = "encoding_failed"
case queueFull = "queue_full"
case unavailable
}
@@ -1,5 +1,6 @@
public import CMUXMobileCore
import CmuxMobileBrowserStream
import CmuxMobileDiagnostics
import CmuxMobileRPC
import Foundation
@@ -17,6 +18,35 @@ extension MobileShellComposite {
browserStreamEvents?.replaceBrowserPanels(in: workspaceID, with: panels)
}
/// Creates a new Mac browser panel in a workspace so the phone can stream
/// it with the same surface as discovered panels.
/// - Parameter workspaceID: The Mac-local workspace identifier.
/// - Returns: The created panel's descriptor, or `nil` when creation is
/// unsupported, disconnected, or rejected by the Mac.
public func createMobileBrowserPanel(workspaceID: String) async -> MobileBrowserPanelDescriptor? {
guard connectionState == .connected,
supportsBrowserStreamCreate,
let client = remoteClient else {
MobileDebugLog.anchormux(
"browser.create skipped connected=\(connectionState == .connected ? 1 : 0) supported=\(supportsBrowserStreamCreate ? 1 : 0)"
)
return nil
}
guard let descriptor = try? await client.createMobileBrowserPanel(workspaceID: workspaceID),
remoteClient === client else {
// The Mac may have committed the panel even though the outcome was
// lost (timeout, decode failure, or a client swap mid-flight).
// Reconcile discovery so a committed panel surfaces in the picker
// instead of becoming an orphan the phone never learns about.
MobileDebugLog.anchormux("browser.create uncertain-failure ws=\(workspaceID.prefix(8)) reconciling")
await refreshMobileBrowserPanels(workspaceID: workspaceID)
return nil
}
MobileDebugLog.anchormux("browser.create ok panel=\(descriptor.panelID.prefix(8))")
browserStreamEvents?.browserPanelCreated(descriptor)
return descriptor
}
/// Starts streaming a discovered Mac browser panel.
/// - Parameter panelID: The Mac browser panel identifier.
public func startMobileBrowserStream(panelID: String) async {
@@ -33,7 +63,10 @@ extension MobileShellComposite {
let viewport = supportsBrowserStreamViewport
? browserStreamEvents?.browserStreamViewport(for: panelID)
: nil
guard !supportsBrowserStreamViewport || viewport != nil else { return }
guard !supportsBrowserStreamViewport || viewport != nil else {
MobileDebugLog.anchormux("browser.stream start-deferred panel=\(panelID.prefix(8)) awaiting-viewport")
return
}
await browserStreamEvents?.browserStreamWillStart(panelID: panelID)
guard connectionState == .connected,
supportsBrowserStream,
@@ -43,8 +76,12 @@ extension MobileShellComposite {
viewport: viewport
),
connectionState == .connected,
remoteClient === client else { return }
remoteClient === client else {
MobileDebugLog.anchormux("browser.stream start-failed panel=\(panelID.prefix(8))")
return
}
startedMobileBrowserPanelIDs.insert(panelID)
MobileDebugLog.anchormux("browser.stream started panel=\(panelID.prefix(8))")
browserStreamEvents?.browserStreamDidStart(descriptor)
}
@@ -162,6 +199,7 @@ extension MobileShellComposite {
func handleMobileBrowserClosedEvent(_ event: MobileEventEnvelope) {
guard let payload = event.payloadJSON else { return }
if let panelID = browserStreamEvents?.receiveBrowserClosedPayload(payload) {
MobileDebugLog.anchormux("browser.stream closed-by-mac panel=\(panelID.prefix(8))")
startedMobileBrowserPanelIDs.remove(panelID)
}
}
@@ -197,6 +235,7 @@ extension MobileShellComposite {
/// started-dedupe set must not suppress the re-arm in that case, or the
/// mirror freezes with no path back short of closing the surface.
func forceRestartMobileBrowserStream(panelID: String) async {
MobileDebugLog.anchormux("browser.stream force-restart panel=\(panelID.prefix(8))")
startedMobileBrowserPanelIDs.remove(panelID)
await startMobileBrowserStream(panelID: panelID)
}
@@ -215,6 +254,7 @@ extension MobileShellComposite {
private func performStopMobileBrowserStream(panelID: String) async {
startedMobileBrowserPanelIDs.remove(panelID)
MobileDebugLog.anchormux("browser.stream stop panel=\(panelID.prefix(8))")
guard let client = remoteClient else { return }
_ = try? await client.stopMobileBrowserStream(panelID: panelID)
}
@@ -9,6 +9,10 @@ extension MobileShellComposite {
public var supportsBrowserStreamDialogs: Bool {
supportsBrowserStream && supportedHostCapabilities.contains(Self.browserStreamDialogCapability)
}
/// Whether the connected Mac can create a browser panel for the phone to stream.
public var supportsBrowserStreamCreate: Bool {
supportsBrowserStream && supportedHostCapabilities.contains(Self.browserStreamCreateCapability)
}
static let chatArtifactFoldersCapability = "chat.artifact.folders.v1"
static let terminalArtifactListCapability = "terminal.artifact.list.v1"
@@ -0,0 +1,21 @@
import CmuxMobileShellModel
@MainActor
extension MobileShellComposite {
/// Observes the shared Settings/onboarding choice and replaces any live
/// foreground connection whose route was selected under the old method.
func startObservingConnectionMethodChanges() {
guard connectionMethodObservationTask == nil,
let connectionMethodStore else { return }
let initialMethod = connectionMethodStore.method
connectionMethodObservationTask = Task { @MainActor [weak self, connectionMethodStore] in
var observedMethod = initialMethod
for await method in connectionMethodStore.changes() {
guard let self, !Task.isCancelled else { return }
guard method != observedMethod else { continue }
observedMethod = method
self.recoverMobileConnection(trigger: .connectionMethodChanged)
}
}
}
}
@@ -85,7 +85,7 @@ extension MobileShellComposite {
}
if let accountID = identityProvider?.currentUserID {
switch trigger {
case .manual, .networkChange, .foreground:
case .manual, .networkChange, .foreground, .connectionMethodChanged:
clearTransientAutomaticReconnectBackoff(accountID: accountID)
case .presencePush:
guard !automaticIrohReconnectIsBlocked(accountID: accountID) else {
@@ -96,10 +96,24 @@ extension MobileShellComposite {
break
}
}
let connectionMethodChanged: Bool
if case .connectionMethodChanged = trigger {
connectionMethodChanged = true
// A method change invalidates every route decision made by an
// in-flight recovery. The replacement below owns a new generation
// and is the only attempt allowed to publish a foreground client.
connectionRecoveryOwner.cancel()
applyConnectionRecoveryOwnerState()
invalidateStoredMacReconnectAttempt()
} else {
connectionMethodChanged = false
}
beginConnectionRecovery(
trigger: trigger,
expectedClient: remoteClient,
probeCurrentConnection: connectionState == .connected && remoteClient != nil,
probeCurrentConnection: !connectionMethodChanged
&& connectionState == .connected
&& remoteClient != nil,
resyncAfterHealthy: true
)
// A disconnected redial has cleared its foreground identity. Starting
@@ -198,7 +212,8 @@ extension MobileShellComposite {
markMacConnectionReconnecting()
resyncTerminalOutput(reason: trigger.description, restartEventStream: true)
case .manual, .presencePush, .foreground, .eventStreamEnded,
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired:
.subscriptionStartFailed, .transportWriteTimedOut, .automaticBackoffExpired,
.connectionMethodChanged:
markMacConnectionUnavailableIfNoStore()
}
return
@@ -615,8 +630,8 @@ extension MobileShellComposite {
routes,
supportedKinds: supportedKinds,
preferNonLoopback: Self.prefersNonLoopbackRoutes,
tailscalePreference: connectionMethodStore?.method == .tailscale
? Self.TailscaleRoutePreference(
tailscaleRequirement: connectionMethodStore?.method == .tailscale
? Self.TailscaleRouteRequirement(
macDeviceID: pairedMacDeviceID,
grantRoutes: legacyTailscaleRoutes
)
@@ -404,10 +404,45 @@ extension MobileShellComposite {
}
}
/// Unhides one stored pairing immediately without requiring network access.
/// Unhides one stored pairing without requiring network access.
public func unhideMacDeviceID(
_ macDeviceID: String,
instanceTag: String? = nil
) async {
await enqueueUnhideMacDeviceID(
macDeviceID,
instanceTag: instanceTag
).value
}
/// Starts an owned unhide operation for a row visibility switch.
public func requestUnhideMacDeviceID(
_ macDeviceID: String,
instanceTag: String? = nil
) {
_ = enqueueUnhideMacDeviceID(macDeviceID, instanceTag: instanceTag)
}
private func enqueueUnhideMacDeviceID(
_ macDeviceID: String,
instanceTag: String?
) -> Task<Void, Never> {
enqueueComputerVisibilityMutation(
computerID: MobilePairedMac.pairingID(
macDeviceID: macDeviceID,
instanceTag: instanceTag
)
) { store in
await store.performUnhideMacDeviceID(
macDeviceID,
instanceTag: instanceTag
)
}
}
private func performUnhideMacDeviceID(
_ macDeviceID: String,
instanceTag: String?
) async {
guard let scope = await currentScopeSnapshot() else { return }
await clearHiddenMacDeviceID(
@@ -415,8 +450,11 @@ extension MobileShellComposite {
instanceTag: instanceTag,
scope: scope
)
guard await isScopeCurrent(scope) else { return }
guard !Task.isCancelled,
await isScopeCurrent(scope),
!Task.isCancelled else { return }
await loadPairedMacs()
guard !Task.isCancelled else { return }
await loadRegistryDevices()
}
@@ -457,6 +495,45 @@ extension MobileShellComposite {
public func hideStoredPairedMacEntries(
representativeID: String,
aliasIDs: [String]
) async {
await enqueueHideStoredPairedMacEntries(
representativeID: representativeID,
aliasIDs: aliasIDs,
refreshRegistry: false
).value
}
/// Starts an owned hide operation for a row visibility switch.
public func requestHideStoredPairedMacEntries(
representativeID: String,
aliasIDs: [String]
) {
_ = enqueueHideStoredPairedMacEntries(
representativeID: representativeID,
aliasIDs: aliasIDs,
refreshRegistry: true
)
}
private func enqueueHideStoredPairedMacEntries(
representativeID: String,
aliasIDs: [String],
refreshRegistry: Bool
) -> Task<Void, Never> {
enqueueComputerVisibilityMutation(computerID: representativeID) { store in
await store.performHideStoredPairedMacEntries(
representativeID: representativeID,
aliasIDs: aliasIDs
)
if refreshRegistry, !Task.isCancelled {
await store.loadRegistryDevices()
}
}
}
private func performHideStoredPairedMacEntries(
representativeID: String,
aliasIDs: [String]
) async {
guard !representativeID.isEmpty,
let scope = await currentScopeSnapshot() else { return }
@@ -478,6 +555,54 @@ extension MobileShellComposite {
await hideStoredPairedMacs(targets, scope: scope)
}
private func enqueueComputerVisibilityMutation(
computerID: String,
operation: @escaping @MainActor (MobileShellComposite) async -> Void
) -> Task<Void, Never> {
let previousTask = computerVisibilityMutationTasksByID[computerID]
let operationID = UUID()
computerVisibilityMutationOperationIDsByID[computerID] = operationID
computerVisibilityMutationIDs.insert(computerID)
let task = Task { @MainActor [weak self] in
await previousTask?.value
guard let self else { return }
defer {
self.finishComputerVisibilityMutation(
computerID: computerID,
operationID: operationID
)
}
guard !Task.isCancelled else { return }
await operation(self)
}
computerVisibilityMutationTasksByID[computerID] = task
return task
}
func cancelComputerVisibilityMutations() {
let tasks = Array(computerVisibilityMutationTasksByID.values)
computerVisibilityMutationIDs = []
for task in tasks {
task.cancel()
}
// Keep each cancelled task as the serial tail until its rollback ends.
// A request in the next account/team scope must await that cleanup before
// writing a newer durable visibility preference for the same computer.
}
private func finishComputerVisibilityMutation(
computerID: String,
operationID: UUID
) {
guard computerVisibilityMutationOperationIDsByID[computerID] == operationID else {
return
}
computerVisibilityMutationTasksByID[computerID] = nil
computerVisibilityMutationOperationIDsByID[computerID] = nil
computerVisibilityMutationIDs.remove(computerID)
}
/// Hides exactly one stored paired-Mac row.
public func hideStoredMac(macDeviceID: String) async {
await hideStoredPairedMacEntries(
@@ -514,7 +639,9 @@ extension MobileShellComposite {
includeUserWideScope: teamlessLegacyIDs.contains(mac.id)
)
}
guard await isScopeCurrent(scope) else {
guard !Task.isCancelled,
await isScopeCurrent(scope),
!Task.isCancelled else {
for pairingID in targetPairingIDs {
await clearHiddenMacDeviceID(pairingID, scope: scope)
}
@@ -571,7 +698,9 @@ extension MobileShellComposite {
removeNotificationFeedSnapshot(macDeviceID: id)
}
guard await isScopeCurrent(scope) else { return }
guard !Task.isCancelled,
await isScopeCurrent(scope),
!Task.isCancelled else { return }
await loadPairedMacs()
clearSavedMacHintWhenNoStoredMacsRemainIfNeeded()
}
@@ -110,9 +110,9 @@ extension MobileShellComposite {
return nil
}
/// The Tailscale ordering preference for one paired Mac: which grant routes
/// may promote an exact stored Tailscale route ahead of the Iroh pin.
struct TailscaleRoutePreference {
/// The strict Tailscale policy for one paired Mac: only exact grant routes
/// remain dialable while the user has selected Tailscale.
struct TailscaleRouteRequirement {
let macDeviceID: String
let grantRoutes: [CmxAttachRoute]
}
@@ -127,16 +127,15 @@ extension MobileShellComposite {
/// or revocation failure could silently downgrade around the Iroh device
/// grant. Pairings without an authenticated Iroh identity remain fail-closed.
///
/// `tailscalePreference` (the user's explicit Tailscale connection-method
/// choice) relaxes only the ORDER of that pin: stored Tailscale routes that
/// carry a device-local grant dial first, and the Iroh routes stay as the
/// fallback instead of being exclusive. Unauthorized Tailscale routes are
/// still never dialable, so a preference flip alone grants nothing.
/// `tailscaleRequirement` represents the user's explicit Tailscale-only
/// connection method. Only stored Tailscale routes carrying a device-local
/// grant remain; Iroh is not retained as a fallback, and a method change
/// alone grants nothing.
static func storedReconnectRoutes(
_ routes: [CmxAttachRoute],
supportedKinds: [CmxAttachTransportKind],
preferNonLoopback: Bool = false,
tailscalePreference: TailscaleRoutePreference? = nil
tailscaleRequirement: TailscaleRouteRequirement? = nil
) -> [CmxAttachRoute] {
let supportedKinds = Set(supportedKinds)
var ordered = CmxAttachRoute.addingIrohPrivatePaths(
@@ -149,20 +148,15 @@ extension MobileShellComposite {
ordered.removeAll { $0.kind == .debugLoopback }
}
let irohRoutes = ordered.filter { $0.kind == .iroh }
if let tailscalePreference {
if let tailscaleRequirement {
let authorizedTailscale = ordered.filter { route in
legacyTailscaleAuthorizationEvidence(
for: route,
macDeviceID: tailscalePreference.macDeviceID,
persistedRoutes: tailscalePreference.grantRoutes
macDeviceID: tailscaleRequirement.macDeviceID,
persistedRoutes: tailscaleRequirement.grantRoutes
) != nil
}
if !authorizedTailscale.isEmpty {
let rest = ordered.filter { route in
route.kind != .iroh && route.kind != .tailscale
}
return authorizedTailscale + irohRoutes + rest
}
return authorizedTailscale
}
if !irohRoutes.isEmpty {
return irohRoutes
@@ -172,7 +166,7 @@ extension MobileShellComposite {
/// The dial order for one stored Mac, honoring the user's connection-method
/// choice. With the default automatic method this is exactly
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscalePreference:)``
/// ``storedReconnectRoutes(_:supportedKinds:preferNonLoopback:tailscaleRequirement:)``
/// without a preference.
func orderedReconnectRoutes(
for mac: MobilePairedMac,
@@ -182,8 +176,8 @@ extension MobileShellComposite {
mac.routes,
supportedKinds: supportedKinds,
preferNonLoopback: Self.prefersNonLoopbackRoutes,
tailscalePreference: connectionMethodStore?.method == .tailscale
? TailscaleRoutePreference(
tailscaleRequirement: connectionMethodStore?.method == .tailscale
? TailscaleRouteRequirement(
macDeviceID: mac.macDeviceID,
grantRoutes: mac.legacyTailscaleRoutes ?? []
)
@@ -414,6 +414,7 @@ extension MobileShellComposite {
// original device-id spelling, which is what the store accepted when
// this control connection was established.
let macID = sub.macDeviceID
let priorSecondaryGroups = workspacesByMac[ownerKey]?.groups ?? []
guard let scope = await currentScopeSnapshot() else {
await retireSecondaryPromotionCandidate(sub)
return .unavailable
@@ -732,7 +733,7 @@ extension MobileShellComposite {
)
return .unavailable
}
guard case let .received(authoritativePreviews) =
guard case let .received(authoritativeSnapshot) =
authoritativeWorkspaceAttempt else {
stopTerminalRefreshPolling()
await retirePromotedConnectionForFreshDial(
@@ -776,7 +777,10 @@ extension MobileShellComposite {
macDeviceID: macID,
instanceTag: activeMacInstanceTag,
displayName: displayName,
workspaces: authoritativePreviews,
workspaces: authoritativeSnapshot.workspaces,
groups: authoritativeSnapshot.groups
?? workspacesByMac[foregroundMacKey]?.groups
?? priorSecondaryGroups,
status: .connected,
actionCapabilities: sub.actionCapabilities
)
@@ -227,7 +227,7 @@ extension MobileShellComposite {
}
var params = workspaceMutationParams(id: id)
if let groupID {
params["group_id"] = groupID.rawValue
params["group_id"] = remoteWorkspaceGroupID(for: groupID).rawValue
}
if let beforeWorkspaceID {
params["before_workspace_id"] = remoteWorkspaceID(for: beforeWorkspaceID).rawValue
@@ -429,7 +429,10 @@ extension MobileShellComposite {
MobileDebugLog.anchormux("workspace.mutation blocked action=\(actionName) id=\(id.rawValue) reason=scope")
return .failure(.authorizationFailed(hostDisplayName: hostDisplayName))
}
var params: [String: Any] = ["group_id": id.rawValue, "action": action]
var params: [String: Any] = [
"group_id": remoteWorkspaceGroupID(for: id).rawValue,
"action": action,
]
if let title {
params["title"] = title
}
@@ -626,9 +629,11 @@ extension MobileShellComposite {
/// - id: The group to collapse or expand.
/// - collapsed: `true` to collapse (hide members), `false` to expand.
public func setWorkspaceGroupCollapsed(id: MobileWorkspaceGroupPreview.ID, _ collapsed: Bool) async {
groupCollapseStore.set(id.rawValue, collapsed: collapsed)
if let index = workspaceGroups.firstIndex(where: { $0.id == id }) {
workspaceGroups[index].isCollapsed = collapsed
}
guard let index = workspaceGroups.firstIndex(where: { $0.id == id }) else { return }
groupCollapseStore.set(
workspaceGroups[index].collapseStateID,
collapsed: collapsed
)
workspaceGroups[index].isCollapsed = collapsed
}
}
@@ -76,7 +76,7 @@ extension MobileShellComposite {
do {
var params: [String: Any] = [:]
if let groupID {
params["group_id"] = groupID.rawValue
params["group_id"] = remoteWorkspaceGroupID(for: groupID).rawValue
}
if let title = spec?.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
params["title"] = title
@@ -54,6 +54,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
"workspace.updated", "mobile.sync.delta",
"terminal.bytes", "terminal.render_grid", "terminal.set_font",
"notification.dismissed", "notification.badge", "notification.feed.changed",
"phone_push.status.changed",
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
]
case .renderGrid:
@@ -61,6 +62,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
"workspace.updated", "mobile.sync.delta",
"terminal.render_grid", "terminal.set_font",
"notification.dismissed", "notification.badge", "notification.feed.changed",
"phone_push.status.changed",
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
]
case .rawBytes:
@@ -68,6 +70,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
"workspace.updated", "mobile.sync.delta",
"terminal.bytes", "terminal.set_font",
"notification.dismissed", "notification.badge", "notification.feed.changed",
"phone_push.status.changed",
"browser.frame", "browser.state", "browser.closed", "browser.dialog", "browser.dialog.resolved",
]
}
@@ -108,6 +111,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
static let browserStreamCapability = MobileBrowserStreamCapability.identifier
static let browserStreamViewportCapability = MobileBrowserStreamCapability.viewportIdentifier
static let browserStreamDialogCapability = MobileBrowserStreamCapability.dialogIdentifier
static let browserStreamCreateCapability = MobileBrowserStreamCapability.createIdentifier
static let terminalReplayCapability = "terminal.replay.v1"
static let terminalInputOrderedCapability = "terminal.input.ordered.v1"
static let maxTerminalReplayBarrierDroppedOutputBeforeFailOpen: UInt64 = 256
@@ -130,7 +134,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
static let dogfoodFeedbackCapability = "dogfood.v1"
static let workspaceGroupsCapability = "workspace.groups.v1"
static let notificationFeedCapability = "notification.feed.v1"
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
static let phonePushSettingsCapability = "phone_push.settings.v1"
static let phonePushTestCapability = "phone_push.test.v1"
nonisolated private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000
/// How long the render-grid stream may stay silent (no event of any topic)
/// before the liveness watchdog suspects the push subscription is dead and
/// runs a bounded host probe; only repeated failed probes force the
@@ -362,10 +368,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
public private(set) var notificationFeedUnreadCount: Int = 0
/// Last authoritative chat-session snapshots, keyed by the workspace row id the UI renders.
var chatSessionSnapshotsByWorkspaceID: [String: [ChatSessionDescriptor]] = [:]
/// The group sections the UI renders. A materialized derivation of
/// ``workspacesByMac`` (currently the foreground Mac's groups). Each group's
/// `isCollapsed` reflects this device's choice (see ``groupCollapseStore``),
/// not the Mac's live value.
/// The group sections the UI renders. A materialized derivation of every
/// entry in ``workspacesByMac``. Each group's `isCollapsed` reflects this
/// device's choice (see ``groupCollapseStore``), not the Mac's live value.
public internal(set) var workspaceGroups: [MobileWorkspaceGroupPreview] = []
/// The distinct per-Mac color index map (the SAME assignment the aggregated
@@ -446,6 +451,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
}
/// Authenticated phone-forwarding readiness from the focused Mac. `nil`
/// means no attached Mac has proved same-account ownership and exposed the
/// independent Mac privacy gate.
public internal(set) var phonePushMacStatus: MobileHostPhonePushStatus?
/// Whether the authenticated Mac supports changing its independent phone
/// forwarding privacy gates from iOS.
public var supportsPhonePushSettings: Bool {
supportedHostCapabilities.contains(Self.phonePushSettingsCapability)
}
/// Whether the authenticated Mac can enqueue a correlated test alert.
public var supportsPhonePushTest: Bool {
supportedHostCapabilities.contains(Self.phonePushTestCapability)
}
/// Published workspace-list chip snapshots keyed by Mac-local workspace id.
///
/// Like ``workspaces``, this is a materialized immutable-value surface on the
@@ -495,6 +515,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
b: terminalInputText.isEmpty ? 1 : 0
))
#endif
if !isLoadingDraft,
!terminalInputText.isEmpty,
terminalInputText != oldValue,
let terminalID = selectedTerminalID?.rawValue {
clearSettledTerminalSendStatus(forTerminalID: terminalID)
}
// Persist the live edit under the CURRENT terminal so it survives a
// terminal switch. Skipped while a draft is being loaded (the load is
// the saved value, re-saving it is redundant and would race the
@@ -575,12 +601,66 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// this spans the entire image-then-text run. Not observed: it gates an
/// async flow, not view state.
@ObservationIgnored private var isSubmittingComposer = false
/// The last user-visible send settlement for each terminal. Unlike the
/// re-entrancy flags above, this is observed by both the composer button and
/// terminal command status pill.
private var terminalSendStatusesByTerminalID: [String: MobileTerminalSendStatus] = [:]
/// Latest operation identity per terminal. A late result from an older send
/// cannot overwrite the state of a newer retry.
@ObservationIgnored private var terminalSendOperationIDsByTerminalID: [String: UUID] = [:]
/// Raw-command operations are tracked separately so a focus/connection
/// pipeline clear can settle only those sends without disturbing an
/// independently in-flight composer paste pinned to the same terminal.
@ObservationIgnored private var rawTerminalSendOperationIDsByTerminalID: [String: UUID] = [:]
/// Pending image attachments per terminal, keyed by terminal id so switching
/// terminals keeps each draft's own attachments (mirroring how the text draft
/// is keyed). Observed so the composer's chip row re-renders on add/remove.
/// Sent in order on the next submit and then cleared for that terminal.
private var pendingAttachmentsByTerminalID: [String: [MobilePendingAttachment]] = [:]
public func terminalSendStatus(forTerminalID terminalID: String) -> MobileTerminalSendStatus {
terminalSendStatusesByTerminalID[terminalID] ?? .idle
}
@discardableResult
private func beginTerminalSend(forTerminalID terminalID: String) -> UUID {
let operationID = UUID()
terminalSendOperationIDsByTerminalID[terminalID] = operationID
terminalSendStatusesByTerminalID[terminalID] = .sending
return operationID
}
private func finishTerminalSend(
_ operationID: UUID?,
forTerminalID terminalID: String,
succeeded: Bool
) {
guard let operationID,
terminalSendOperationIDsByTerminalID[terminalID] == operationID else { return }
terminalSendStatusesByTerminalID[terminalID] = succeeded ? .sent : .failed
}
private func clearSettledTerminalSendStatus(forTerminalID terminalID: String) {
guard terminalSendStatusesByTerminalID[terminalID] != .sending else { return }
terminalSendStatusesByTerminalID[terminalID] = nil
terminalSendOperationIDsByTerminalID[terminalID] = nil
}
private func finishRawTerminalSend(
_ operationID: UUID?,
forTerminalID terminalID: String,
succeeded: Bool
) {
guard let operationID,
rawTerminalSendOperationIDsByTerminalID[terminalID] == operationID else { return }
rawTerminalSendOperationIDsByTerminalID[terminalID] = nil
finishTerminalSend(
operationID,
forTerminalID: terminalID,
succeeded: succeeded
)
}
/// Max number of staged attachments per terminal. Enforced in
/// ``addPendingAttachment(_:format:forTerminalID:)`` against the CURRENT
/// staged set at mutation time so the check+insert is atomic on the main
@@ -717,8 +797,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let runtime: (any MobileSyncRuntime)?
let pairedMacStore: (any MobilePairedMacStoring)?
/// The user's connection-method choice. `nil` (previews/tests without one)
/// behaves like the default automatic method.
/// The user's connection-method choice. The shipping app always injects
/// this at the composition root (`AppCompositionRoot` holds it
/// non-optional), so a user-selected Tailscale Only choice can never be
/// dropped at runtime. `nil` exists only for DEBUG previews, the
/// hide-computers verifier, and unit-test fixtures, which have no user
/// preference and behave like the default automatic method.
let connectionMethodStore: MobileConnectionMethodStore?
/// Single compatibility authority shared by registry, persistence, and live connections.
let buildCompatibilityPolicy: MobileMacBuildCompatibilityPolicy?
@@ -1252,6 +1336,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
workspaces.first { $0.id == id }?.rpcWorkspaceID ?? id
}
/// Resolve an aggregate group id back to the Mac-local id expected by RPC.
func remoteWorkspaceGroupID(
for id: MobileWorkspaceGroupPreview.ID
) -> MobileWorkspaceGroupPreview.ID {
workspaceGroups.first { $0.id == id }?.rpcGroupID ?? id
}
/// Resolve a Mac-local workspace id to the current UI row id.
func rowWorkspaceID(
forRemoteWorkspaceID remoteID: MobileWorkspacePreview.ID,
@@ -1511,6 +1602,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
browserStreamEvents?.configureBrowserStreamRestart { [weak self] panelID in
await self?.forceRestartMobileBrowserStream(panelID: panelID)
}
startObservingConnectionMethodChanges()
}
isolated deinit {
@@ -1518,6 +1610,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
automaticReconnectRetryTask?.cancel()
presenceTask?.cancel()
networkPathObservationTask?.cancel()
connectionMethodObservationTask?.cancel()
terminalEventListenerTask?.cancel()
terminalSubscriptionStartTask?.cancel()
renderGridLivenessTimer?.cancel()
@@ -1532,6 +1625,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
workspaceChangesSummaryTrailingTask?.cancel()
pullToRefreshTask?.cancel()
foregroundWorkspaceMutationRefreshTask?.cancel()
for task in computerVisibilityMutationTasksByID.values {
task.cancel()
}
foregroundWorkspaceMutationRefreshPending = false
foregroundWorkspaceMutationRefreshGeneration = UUID()
notificationFeedOpenTask?.cancel()
@@ -1577,6 +1673,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
public func signOut() {
cancelComputerVisibilityMutations()
// Reset analytics identity to anonymous on the signed-insigned-out edge
// only (this is called on every unauthenticated auth-state sync).
if isSignedIn {
@@ -1733,6 +1830,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// never drops the terminal the user is in (the chosen "keep session, re-scope
/// lists" behavior).
public func currentTeamDidChange() {
cancelComputerVisibilityMutations()
secondaryAggregationScopeGeneration &+= 1
let teamScopeGeneration = secondaryAggregationScopeGeneration
// Presence: cancel + re-subscribe so the online dots reflect the new team
@@ -1975,6 +2073,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
var networkPathObservationStarted = false
var networkPathObservationTask: Task<Void, Never>?
var connectionMethodObservationTask: Task<Void, Never>?
let connectionRecoveryOwner = MobileConnectionRecoveryOwner()
var lastReconnectStackUserID: String?
/// Whether the scene is in the active phase. Set by
@@ -1999,6 +2098,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
case subscriptionStartFailed
case transportWriteTimedOut
case automaticBackoffExpired
case connectionMethodChanged
var reschedulesSecondaryAggregation: Bool { self != .presencePush }
@@ -2016,6 +2116,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
case .subscriptionStartFailed: 7
case .transportWriteTimedOut: 8
case .automaticBackoffExpired: 9
case .connectionMethodChanged: 10
}
}
@@ -2030,6 +2131,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
case .subscriptionStartFailed: return "subscriptionStartFailed"
case .transportWriteTimedOut: return "transportWriteTimedOut"
case .automaticBackoffExpired: return "automaticBackoffExpired"
case .connectionMethodChanged: return "connectionMethodChanged"
}
}
}
@@ -2450,7 +2552,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
if hasKnownStoredMac {
setHasKnownPairedMac(true, generation: generation)
}
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
let tailscaleOnly = connectionMethodStore?.method == .tailscale
let irohReconnectIsBlocked = tailscaleOnly
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
// Capture one coherent post-request view of the registry and paired-Mac
// store. The store read happens after the registry await, so an
// authenticated Presence write that lands during the request wins. The
@@ -2480,7 +2584,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
instanceTag: mac.instanceTag,
scope: scope
) else { break }
let irohReconnectIsBlocked = automaticIrohReconnectIsBlocked(accountID: scope.userID)
let irohReconnectIsBlocked = tailscaleOnly
|| automaticIrohReconnectIsBlocked(accountID: scope.userID)
let localRoutes = storedReconnectRoutes(mac).filter {
!irohReconnectIsBlocked || $0.kind != .iroh
}
@@ -2514,7 +2619,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
)
}
if connectionState != .connected,
if connectionState != .connected, !tailscaleOnly,
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
switch await freshReconnectRoutesAfterLocalFailure(
for: mac,
@@ -2553,7 +2658,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// saved candidate failed. This keeps a healthy saved Mac from sitting
// behind an unrelated account-wide discovery request.
var zeroTouchCandidates: [MobilePairedMac] = []
if connectionState != .connected,
if connectionState != .connected, !tailscaleOnly,
!automaticIrohReconnectIsBlocked(accountID: scope.userID) {
zeroTouchCandidates = await discoverZeroTouchIrohCandidates(
scope: scope,
@@ -2680,6 +2785,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
public internal(set) var hiddenComputers: [MobileHiddenComputer] = []
/// True when the current account/team scope has at least one hidden computer.
public internal(set) var hasHiddenComputers = false
/// Computer identities whose visibility preference is being persisted.
public internal(set) var computerVisibilityMutationIDs: Set<String> = []
@ObservationIgnored var computerVisibilityMutationTasksByID: [String: Task<Void, Never>] = [:]
@ObservationIgnored var computerVisibilityMutationOperationIDsByID: [String: UUID] = [:]
var pairedMacsForIdentityMatching: [MobilePairedMac] {
storedPairedMacs.isEmpty ? pairedMacs : storedPairedMacs
@@ -4461,11 +4570,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
return .permanentFailure
}
return .received(response.workspaces.map { remote in
let workspaces = response.workspaces.map { remote in
var workspace = MobileWorkspacePreview(remote: remote)
workspace.macDeviceID = macDeviceID
return workspace
})
}
let groups = Self.remoteWorkspaceGroups(
from: response,
acceptsEmptyGroupSnapshot: !response.workspaces.contains { workspace in
workspace.groupID?.isEmpty == false
}
)
return .received(SecondaryWorkspaceSnapshot(
workspaces: workspaces,
groups: groups
))
}
/// Ensure a live read-only subscription exists for every signed-in paired Mac
@@ -6072,13 +6191,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let wasSuperseded =
subscription.workspaceRefreshGeneration
!= requestGeneration
let previews: [MobileWorkspacePreview]
let snapshot: SecondaryWorkspaceSnapshot
switch attempt {
case let .received(value):
// Publish every successful snapshot. Discarding a leading
// success lets sustained event churn starve the aggregate
// forever while requests keep completing.
previews = value
snapshot = value
case .transientFailure:
if wasSuperseded, completedPassCount < 2 {
continue refreshLoop
@@ -6097,7 +6216,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
macDeviceID: macID,
instanceTag: subscription.storedInstanceTag,
displayName: displayName ?? subscription.displayName,
workspaces: previews,
workspaces: snapshot.workspaces,
groups: snapshot.groups
?? self.workspacesByMac[ownerKey]?.groups
?? [],
status: .connected,
actionCapabilities: subscription.actionCapabilities
)
@@ -6439,8 +6561,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let statesByAggregateKey = Dictionary(
uniqueKeysWithValues: workspacesByMac.map { ($0.key.pairingID, $0.value) }
)
let macIDsInDisplayOrder = workspaceAggregation.orderedMacIDs(
statesByMac: statesByAggregateKey,
foregroundMacDeviceID: foregroundKey
)
var derived = workspaceAggregation.derivedWorkspaces(
statesByMac: statesByAggregateKey, foregroundMacDeviceID: foregroundKey, machineColorIndex: stableMacColorSlots)
statesByMac: statesByAggregateKey,
foregroundMacDeviceID: foregroundKey,
machineColorIndex: stableMacColorSlots,
macIDsInDisplayOrder: macIDsInDisplayOrder
)
// Stamp per-Mac user color/icon overrides from pairedMacs so every
// workspace avatar matches its computer's customization (same place the
// aggregation already assigned the automatic color index).
@@ -6481,8 +6611,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.selectedWorkspaceID = remapped?.id ?? derived.first?.id
}
if selectedWorkspaceID != nil { syncSelectedTerminalForWorkspace() }
workspaceGroups = workspaceAggregation.derivedGroups(
statesByMac: statesByAggregateKey, foregroundMacDeviceID: foregroundKey)
let derivedGroups = workspaceAggregation.derivedGroups(
statesByMac: statesByAggregateKey,
foregroundMacDeviceID: foregroundKey,
macIDsInDisplayOrder: macIDsInDisplayOrder
)
workspaceGroups = groupCollapseStore.apply(to: derivedGroups)
}
private func pruneChatSessionSnapshots(to visibleWorkspaces: [MobileWorkspacePreview]) {
@@ -7289,10 +7423,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// send fails (no connection, or an older host that does not implement
/// `terminal.paste` and answers `method_not_found`), the composed text is
/// kept so the user can retry instead of silently losing the message.
public func submitComposerInput() async {
@discardableResult
public func submitComposerInput() async -> Bool {
guard let workspaceID = selectedWorkspace?.id,
let terminalID = selectedTerminalID else { return }
await submitComposerInput(workspaceID: workspaceID, terminalID: terminalID)
let terminalID = selectedTerminalID else { return false }
return await submitComposerInput(
workspaceID: workspaceID,
terminalID: terminalID
)
}
/// Submit the composer's text to an explicitly captured terminal. Used by
@@ -7364,22 +7502,33 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// attachments staged AND keeps the text unsent, so the user can retry
/// instead of silently losing photos (matching the text-keep-on-failure
/// semantics of ``submitComposerInput()``).
public func submitComposer() async {
@discardableResult
public func submitComposer() async -> Bool {
// Reject a re-entrant submit (e.g. a double tap on Send): the button
// stays enabled while the first image RPC awaits, and a second submit
// would capture the same still-staged attachments and re-upload them.
// Set/cleared on the main actor around the awaits, so no second call can
// slip past. A failed send keeps the attachments staged (below), so the
// user can retry once this flag clears.
guard !isSubmittingComposer else { return }
guard !isSubmittingComposer else { return false }
isSubmittingComposer = true
defer { isSubmittingComposer = false }
guard let workspaceID = selectedWorkspace?.id,
let submittedTerminalID = selectedTerminalID else {
// No target: fall back to the text-only path, which is itself a no-op
// without a selected terminal.
await submitComposerInput()
return
return await submitComposerInput()
}
let sendOperationID = beginTerminalSend(
forTerminalID: submittedTerminalID.rawValue
)
var sendSucceeded = false
defer {
finishTerminalSend(
sendOperationID,
forTerminalID: submittedTerminalID.rawValue,
succeeded: sendSucceeded
)
}
// Snapshot the text BEFORE any await (the image sends below). Threaded
// through the text submit + the post-send reconcile so a terminal switch
@@ -7419,7 +7568,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
signIn: submitSignInGeneration,
connection: submitConnectionGeneration,
client: submitClient
) else { return }
) else { return false }
// Re-check the attachment is still staged for the captured terminal
// before uploading it. The user can delete a not-yet-acked chip while
// an earlier image's send is in flight; that removes it from
@@ -7436,7 +7585,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
workspaceID: workspaceID,
terminalID: submittedTerminalID
)
guard sent else { return }
guard sent else { return false }
removePendingAttachment(id: attachment.id, forTerminalID: submittedTerminalID.rawValue)
}
// Re-check the captured identity one last time before the text send. The
@@ -7448,16 +7597,17 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
signIn: submitSignInGeneration,
connection: submitConnectionGeneration,
client: submitClient
) else { return }
) else { return false }
// Submit the captured text to the captured terminal (a no-op when empty,
// e.g. an images-only send). All images acked by here, so the text
// follows. Passing the snapshot (not the live field) keeps this immune to
// a switch/edit that happened during the image awaits above.
await submitComposerInput(
sendSucceeded = await submitComposerInput(
workspaceID: workspaceID,
terminalID: submittedTerminalID,
capturedText: submittedText
)
return sendSucceeded
}
/// Whether the session + connection identity captured at the start of a
@@ -7539,11 +7689,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
#endif
return
}
let sendStatusOperationID = prepareTerminalSendStatusForRawInput(
text,
terminalID: terminalID.rawValue
)
let enqueueResult = rawTerminalInputBuffer.enqueue(
text,
workspaceID: workspaceID,
terminalID: terminalID
terminalID: terminalID,
sendStatusOperationID: sendStatusOperationID
)
if enqueueResult == .rejected {
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
}
handleSynchronousRawTerminalInputEnqueueResult(enqueueResult)
}
@@ -7553,11 +7715,23 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return
}
guard let workspaceID = workspaceID(forTerminalID: surfaceID) else { return }
let sendStatusOperationID = prepareTerminalSendStatusForRawInput(
text,
terminalID: surfaceID
)
let enqueueResult = rawTerminalInputBuffer.enqueue(
text,
workspaceID: workspaceID,
terminalID: MobileTerminalPreview.ID(rawValue: surfaceID)
terminalID: MobileTerminalPreview.ID(rawValue: surfaceID),
sendStatusOperationID: sendStatusOperationID
)
if enqueueResult == .rejected {
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: surfaceID,
succeeded: false
)
}
handleSynchronousRawTerminalInputEnqueueResult(enqueueResult)
}
@@ -7576,6 +7750,28 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
private func prepareTerminalSendStatusForRawInput(
_ text: String,
terminalID: String
) -> UUID? {
if Self.containsTerminalSubmission(text) {
let operationID = beginTerminalSend(
forTerminalID: terminalID
)
rawTerminalSendOperationIDsByTerminalID[terminalID] = operationID
return operationID
} else {
clearSettledTerminalSendStatus(forTerminalID: terminalID)
return nil
}
}
private nonisolated static func containsTerminalSubmission(_ text: String) -> Bool {
text.unicodeScalars.contains { scalar in
scalar.value == 0x0D || scalar.value == 0x0A
}
}
/// Submit raw text to the currently selected terminal when one is available.
public func submitTerminalRawInput(_ text: String) async {
guard !text.isEmpty else { return }
@@ -7666,7 +7862,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
chunk.text,
workspaceID: chunk.workspaceID,
terminalID: chunk.terminalID,
latencyBatchNumber: latencyBatchNumberForSend
latencyBatchNumber: latencyBatchNumberForSend,
sendStatusOperationID: chunk.sendStatusOperationID
)
}
}
@@ -7681,6 +7878,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
func clearPendingTerminalInputForFocusChange() {
rawTerminalInputBuffer.clear()
terminalInputRPCPipeline.clear()
let pendingRawSends = rawTerminalSendOperationIDsByTerminalID
for (terminalID, operationID) in pendingRawSends {
finishRawTerminalSend(
operationID,
forTerminalID: terminalID,
succeeded: false
)
}
resumeRawTerminalInputDrainWaiters()
}
@@ -8272,6 +8477,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
foregroundMacDeviceID = resolvedForegroundMacID
}
supportedHostCapabilities = authenticatedCapabilities
phonePushMacStatus = status.phonePush
// Publish transport selection with the authenticated
// capability snapshot before exposing `.connected`.
// The listener reuses this same status below, but starts in
@@ -8423,10 +8629,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
let irohRoutes = supportedRoutes.filter { route in
route.kind == .iroh
}
// The user's explicit Tailscale method relaxes only the Iroh pin's
// ORDER: authorized Tailscale routes dial first and Iroh remains the
// fallback. Routes without a grant or a user-entered code stay
// undialable regardless of the preference.
// The explicit Tailscale method is strict: only authorized Tailscale
// destinations may be dialed, and an unavailable route leaves the app
// disconnected instead of silently switching to Iroh.
if connectionMethodStore?.method == .tailscale {
let authorizedTailscale = supportedRoutes.filter { route in
Self.legacyTailscaleAuthorizationEvidence(
@@ -8439,12 +8644,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
authorizations: userTailscalePairingAuthorizations
) != nil
}
if !authorizedTailscale.isEmpty {
let rest = supportedRoutes.filter { route in
route.kind != .iroh && route.kind != .tailscale
}
return authorizedTailscale + irohRoutes + rest
}
return authorizedTailscale
}
return irohRoutes.isEmpty ? supportedRoutes : irohRoutes
}
@@ -9002,6 +9202,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalOutputTransport = .rawBytes
deactivateAllTerminalLanes()
supportedHostCapabilities = []
phonePushMacStatus = nil
clearMacUpdateHint()
terminalSubscriptionRefreshTask?.cancel()
terminalSubscriptionRefreshTask = nil
@@ -9633,13 +9834,19 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
_ text: String,
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID,
latencyBatchNumber: UInt64? = nil
latencyBatchNumber: UInt64? = nil,
sendStatusOperationID: UUID? = nil
) async {
guard let client = remoteClient else {
#if DEBUG
mobileShellLog.info("skip remote terminal input remoteClient=0")
#endif
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
return
}
let generation = connectionGeneration
@@ -9674,6 +9881,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
latencyBatchNumber,
succeeded: false
)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
return
}
if terminalInputRPCPipeline.hasAmbiguousFailure(
@@ -9700,12 +9912,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
switch laneResult {
case .sent:
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: true)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: true
)
return
case .failed:
mobileShellLog.error(
"independent terminal input failed surface=\(terminalID.rawValue, privacy: .public)"
)
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
return
case .unavailable:
break
@@ -9738,6 +9960,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
latencyBatchNumber,
succeeded: true
)
self?.finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: true
)
guard let self, let client else { return }
guard self.isCurrentRemoteOperation(
client: client,
@@ -9752,6 +9979,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
latencyBatchNumber,
succeeded: false
)
self?.finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
guard let self, let client else { return }
self.handleTerminalInputFailure(
error,
@@ -9768,6 +10000,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// operational failure, regardless of whether the caller also
// rotated connectionGeneration.
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
if error is CancellationError { return }
handleTerminalInputFailure(
error,
@@ -9789,12 +10026,27 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
guard isCurrentRemoteOperation(client: client, generation: generation) else {
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
return
}
handleTerminalInputResponse(responseData, surfaceID: terminalID.rawValue)
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: true)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: true
)
} catch {
Self.stampTerminalInputSettlement(latencyBatchNumber, succeeded: false)
finishRawTerminalSend(
sendStatusOperationID,
forTerminalID: terminalID.rawValue,
succeeded: false
)
handleTerminalInputFailure(
error,
client: client,
@@ -10221,6 +10473,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
return .rawBytes
}
supportedHostCapabilities = Set(payload.capabilities)
phonePushMacStatus = payload.phonePush
restartActiveMobileBrowserStreams()
refreshVisibleMobileBrowserPanels()
prepareTerminalThemeRevisionAuthority(
@@ -10441,6 +10694,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
macDeviceID: macDeviceID
)
)
} else if event.topic == "phone_push.status.changed" {
await self.refreshPhonePushStatus(
client: client,
generation: self.connectionGeneration
)
} else if event.topic == "browser.frame" {
self.handleMobileBrowserFrameEvent(event)
} else if event.topic == "browser.state" {
@@ -10467,6 +10725,152 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}
private func refreshPhonePushStatus(
client: MobileCoreRPCClient,
generation: UUID
) async {
let exchange: (response: Data, hostStatusResponse: Data)
do {
exchange = try await client.sendRequestAndAuthenticatedHostStatus(
MobileCoreRPCClient.requestData(
method: "phone_push.status.get",
params: [:]
),
timeoutNanoseconds: Self.terminalOutputCapabilityTimeoutNanoseconds,
hostStatusTimeoutNanoseconds: {
Self.terminalOutputCapabilityTimeoutNanoseconds
}
)
} catch {
guard isCurrentRemoteConnection(
client: client,
generation: generation
) else { return }
// This status probe is authenticated: a definitive authorization
// failure here means the session itself is revoked or mismatched,
// not merely that push readiness is unknown. Route it to the
// shared reauth disconnect instead of staying connected with a
// silently cleared readiness.
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
return
}
phonePushMacStatus = nil
return
}
guard isCurrentRemoteConnection(
client: client,
generation: generation
) else { return }
guard let status = try? MobileHostStatusResponse.decode(
exchange.hostStatusResponse
) else {
phonePushMacStatus = nil
return
}
phonePushMacStatus = status.phonePush
}
/// Applies one or more Mac-owned phone-forwarding settings over the current
/// authenticated attach channel, then reads the authoritative status back.
///
/// The mutation fails closed when the Mac is unavailable, predates the
/// capability, rejects the caller, or the connection changes mid-flight.
/// Local UI never writes a speculative Mac value into this store.
@discardableResult
public func updatePhonePushSettings(
forwardingEnabled: Bool? = nil,
mode: MobileHostPhonePushStatus.Mode? = nil,
hideContent: Bool? = nil
) async -> Bool {
guard supportsPhonePushSettings,
let client = remoteClient,
forwardingEnabled != nil || mode != nil || hideContent != nil
else { return false }
var params: [String: Any] = [:]
if let forwardingEnabled {
params["forwarding_enabled"] = forwardingEnabled
}
if let mode {
params["mode"] = mode.rawValue
}
if let hideContent {
params["hide_content"] = hideContent
}
let generation = connectionGeneration
do {
let exchange = try await client.sendRequestAndAuthenticatedHostStatus(
MobileCoreRPCClient.requestData(
method: "phone_push.settings.update",
params: params
),
hostStatusTimeoutNanoseconds: {
Self.terminalOutputCapabilityTimeoutNanoseconds
}
)
let status = try MobileHostStatusResponse.decode(
exchange.hostStatusResponse
)
guard isCurrentRemoteConnection(
client: client,
generation: generation
) else { return false }
phonePushMacStatus = status.phonePush
return true
} catch {
guard generation == connectionGeneration else { return false }
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
return false
}
handleMacAvailabilityFailureIfCurrent(
after: error,
expectedClient: client,
expectedGeneration: generation
)
return false
}
}
/// Requests a test alert through the Mac's real durable queue and returns
/// only the furthest stage the synchronous RPC can prove.
public func sendPhonePushTest() async -> MobilePhonePushTestStage {
guard supportsPhonePushTest, let client = remoteClient else {
return .unavailable
}
let generation = connectionGeneration
do {
let data = try await client.sendRequest(
MobileCoreRPCClient.requestData(
method: "phone_push.test",
params: [:]
)
)
guard isCurrentRemoteConnection(
client: client,
generation: generation
), let object = try JSONSerialization.jsonObject(with: data)
as? [String: Any],
let rawStage = object["stage"] as? String,
let stage = MobilePhonePushTestStage(rawValue: rawStage)
else { return .unavailable }
return stage
} catch {
guard generation == connectionGeneration else {
return .unavailable
}
guard !disconnectForAuthorizationFailureIfNeeded(error) else {
return .unavailable
}
handleMacAvailabilityFailureIfCurrent(
after: error,
expectedClient: client,
expectedGeneration: generation
)
return .unavailable
}
}
/// Run the `mobile.events.subscribe` (reason `start`) handshake for one
/// listener generation, concurrently with that generation's consumer loop.
///
@@ -12041,13 +12445,22 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
groupsAreAuthoritative: Bool
) -> [MobileWorkspaceGroupPreview]? {
guard !mergeExistingWorkspaces, groupsAreAuthoritative else { return nil }
return Self.remoteWorkspaceGroups(
from: response,
acceptsEmptyGroupSnapshot: canAcceptEmptyGroupSnapshot(from: response)
)
}
/// Resolve one response's group-field completeness into update-or-preserve
/// semantics shared by foreground and secondary workspace snapshots.
private static func remoteWorkspaceGroups(
from response: MobileSyncWorkspaceListResponse,
acceptsEmptyGroupSnapshot: Bool
) -> [MobileWorkspaceGroupPreview]? {
guard response.groupsFieldWasPresent else { return nil }
let groups = response.groups.map { MobileWorkspaceGroupPreview(remote: $0) }
guard groups.isEmpty else {
return groupCollapseStore.apply(to: groups)
}
guard canAcceptEmptyGroupSnapshot(from: response) else { return nil }
return []
guard groups.isEmpty else { return groups }
return acceptsEmptyGroupSnapshot ? [] : nil
}
private func canAcceptEmptyGroupSnapshot(
@@ -1,7 +1,5 @@
import CmuxMobileShellModel
enum SecondaryWorkspaceFetchAttempt {
case received([MobileWorkspacePreview])
case received(SecondaryWorkspaceSnapshot)
case transientFailure
case permanentFailure
}
@@ -0,0 +1,11 @@
import CmuxMobileShellModel
/// One successfully decoded secondary Mac workspace snapshot.
///
/// A `nil` group list means the response did not contain authoritative group
/// metadata, so the caller preserves that Mac's prior group snapshot. An empty
/// list is authoritative and clears its prior groups.
struct SecondaryWorkspaceSnapshot: Equatable, Sendable {
let workspaces: [MobileWorkspacePreview]
let groups: [MobileWorkspaceGroupPreview]?
}
@@ -28,6 +28,7 @@ func makeRoutingConnectedStore(
pairedMacStore: (any MobilePairedMacStoring)? = nil,
routeKind: CmxAttachTransportKind = .debugLoopback,
terminalLaneProvider: MobileTerminalLaneProvider? = nil,
draftStore: (any TerminalDraftStoring)? = nil,
rpcRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000
) async throws -> MobileShellComposite {
let runtime = RoutingTestRuntime(
@@ -53,7 +54,8 @@ func makeRoutingConnectedStore(
],
pairedMacStore: pairedMacStore,
identityProvider: StaticIdentityProvider(userID: "routing-user"),
pendingDismissQueue: pendingDismissQueue
pendingDismissQueue: pendingDismissQueue,
draftStore: draftStore
)
// 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the
// Stack token and do not throw insecureManualRoute before reaching the
@@ -12,6 +12,63 @@ import Testing
@Suite struct ComposerSubmitRoutingTests {
private static func bytes(_ s: String) -> Data { Data(s.utf8) }
@Test func exposesComposerSendProgressAndSettlement() async throws {
let router = RoutingHostRouter()
let store = try await makeRoutingConnectedStore(router: router)
let terminalID = RoutingHostRouter.terminalA
store.selectTerminal(MobileTerminalPreview.ID(rawValue: terminalID))
store.addPendingAttachment(Self.bytes("held"), format: "png", forTerminalID: terminalID)
store.terminalInputText = "hello"
await router.setHoldFirstPasteImage(true)
let submit = Task { await store.submitComposer() }
await router.awaitFirstPasteImageReached()
#expect(store.terminalSendStatus(forTerminalID: terminalID) == .sending)
await router.releaseFirstPasteImage()
await submit.value
#expect(store.terminalSendStatus(forTerminalID: terminalID) == .sent)
}
@Test func exposesComposerSendFailure() async throws {
let router = RoutingHostRouter()
let store = try await makeRoutingConnectedStore(router: router)
let terminalID = RoutingHostRouter.terminalA
store.selectTerminal(MobileTerminalPreview.ID(rawValue: terminalID))
store.addPendingAttachment(Self.bytes("rejected"), format: "png", forTerminalID: terminalID)
await router.setRejectPasteImage(true)
await store.submitComposer()
#expect(store.terminalSendStatus(forTerminalID: terminalID) == .failed)
}
@Test func restoredFailedDraftKeepsFailureSettlement() async throws {
let router = RoutingHostRouter()
let drafts = InMemoryTerminalDraftStore()
let store = try await makeRoutingConnectedStore(router: router, draftStore: drafts)
let termA = RoutingHostRouter.terminalA
let termB = RoutingHostRouter.terminalB
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termA))
await store.drainDraftOperationsForTesting()
store.terminalInputText = "retry me"
store.addPendingAttachment(Self.bytes("rejected"), format: "png", forTerminalID: termA)
await router.setRejectPasteImage(true)
await store.submitComposer()
#expect(store.terminalSendStatus(forTerminalID: termA) == .failed)
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termB))
await store.drainDraftOperationsForTesting()
store.selectTerminal(MobileTerminalPreview.ID(rawValue: termA))
await store.drainDraftOperationsForTesting()
#expect(store.terminalInputText == "retry me")
#expect(store.terminalSendStatus(forTerminalID: termA) == .failed)
}
/// Images and text both go to the selected terminal when nothing switches.
@Test func sendsAttachmentsAndTextToSelectedTerminal() async throws {
let router = RoutingHostRouter()
@@ -0,0 +1,64 @@
@testable import CmuxMobileShell
actor DelayedFirstHidePairedMacHiddenStore: PairedMacHiddenStoring {
private var idsByScope: [String: Set<String>] = [:]
private var didDelayHideSave = false
private var hideSaveStarted = false
private var hideSaveStartWaiters: [CheckedContinuation<Void, Never>] = []
private var hideSaveRelease: CheckedContinuation<Void, Never>?
private var didSaveEmpty = false
private var emptySaveWaiters: [CheckedContinuation<Void, Never>] = []
func load(scope: String) async -> Set<String> {
idsByScope[scope] ?? []
}
func save(_ ids: Set<String>, scope: String) async {
if !didDelayHideSave, !ids.isEmpty {
didDelayHideSave = true
hideSaveStarted = true
let waiters = hideSaveStartWaiters
hideSaveStartWaiters = []
for waiter in waiters {
waiter.resume()
}
await withCheckedContinuation { continuation in
hideSaveRelease = continuation
}
}
if ids.isEmpty {
idsByScope.removeValue(forKey: scope)
didSaveEmpty = true
let waiters = emptySaveWaiters
emptySaveWaiters = []
for waiter in waiters {
waiter.resume()
}
} else {
idsByScope[scope] = ids
}
}
func removeAll() async {
idsByScope.removeAll()
}
func waitForDelayedHideSave() async {
guard !hideSaveStarted else { return }
await withCheckedContinuation { continuation in
hideSaveStartWaiters.append(continuation)
}
}
func releaseDelayedHideSave() {
hideSaveRelease?.resume()
hideSaveRelease = nil
}
func waitForEmptySave() async {
guard !didSaveEmpty else { return }
await withCheckedContinuation { continuation in
emptySaveWaiters.append(continuation)
}
}
}
@@ -69,6 +69,157 @@ import Testing
#expect(store.workspaceListConnectionStatus == .connected)
}
@Test func laterUnhideWinsWhenEarlierHidePersistenceFinishesLast() async throws {
let hiddenStore = DelayedFirstHidePairedMacHiddenStore()
let pairedStore = DelayedTeamPairedMacStore(
recordsByTeam: [
"team-a": [
try Self.pairedMac(
id: "mac-a",
displayName: "Desk Mac",
host: "100.82.214.112",
lastSeenAt: Date(timeIntervalSince1970: 10),
isActive: false
),
],
],
blockedTeams: []
)
let store = MobileShellComposite(
isSignedIn: true,
pairedMacStore: pairedStore,
identityProvider: StaticIdentityProvider(userID: "user-1"),
teamIDProvider: { "team-a" },
hiddenMacStore: hiddenStore
)
await store.loadPairedMacs()
let computer = try #require(store.pairedMacs.first)
let scope = try #require(await store.currentScopeSnapshot())
let scopeKey = store.pairedMacScopeKey(scope)
let hideTask = Task { @MainActor in
await store.hideStoredPairedMacEntries(
representativeID: computer.id,
aliasIDs: [computer.id]
)
}
await hiddenStore.waitForDelayedHideSave()
store.requestUnhideMacDeviceID(
computer.macDeviceID,
instanceTag: computer.instanceTag
)
await hiddenStore.releaseDelayedHideSave()
await hiddenStore.waitForEmptySave()
await hideTask.value
#expect(
await hiddenStore.load(scope: scopeKey).isEmpty,
"The later show request must remain durable after relaunch."
)
}
@Test func signOutCancelsInFlightComputerVisibilityMutation() async throws {
let hiddenStore = DelayedFirstHidePairedMacHiddenStore()
let pairedStore = DelayedTeamPairedMacStore(
recordsByTeam: [
"team-a": [
try Self.pairedMac(
id: "mac-a",
displayName: "Desk Mac",
host: "100.82.214.112",
lastSeenAt: Date(timeIntervalSince1970: 10),
isActive: false
),
],
],
blockedTeams: []
)
let store = MobileShellComposite(
isSignedIn: true,
pairedMacStore: pairedStore,
identityProvider: StaticIdentityProvider(userID: "user-1"),
teamIDProvider: { "team-a" },
hiddenMacStore: hiddenStore
)
await store.loadPairedMacs()
let computer = try #require(store.pairedMacs.first)
let scope = try #require(await store.currentScopeSnapshot())
let scopeKey = store.pairedMacScopeKey(scope)
store.requestHideStoredPairedMacEntries(
representativeID: computer.id,
aliasIDs: [computer.id]
)
await hiddenStore.waitForDelayedHideSave()
let task = try #require(store.computerVisibilityMutationTasksByID[computer.id])
store.signOut()
#expect(task.isCancelled)
#expect(store.computerVisibilityMutationIDs.isEmpty)
#expect(store.computerVisibilityMutationTasksByID[computer.id] != nil)
#expect(store.computerVisibilityMutationOperationIDsByID[computer.id] != nil)
await hiddenStore.releaseDelayedHideSave()
await task.value
#expect(store.computerVisibilityMutationTasksByID.isEmpty)
#expect(store.computerVisibilityMutationOperationIDsByID.isEmpty)
#expect(store.hiddenComputers.isEmpty)
#expect(await hiddenStore.load(scope: scopeKey).isEmpty)
}
@Test func teamChangeCancelsInFlightComputerVisibilityMutation() async throws {
let hiddenStore = DelayedFirstHidePairedMacHiddenStore()
let pairedStore = DelayedTeamPairedMacStore(
recordsByTeam: [
"team-a": [
try Self.pairedMac(
id: "mac-a",
displayName: "Desk Mac",
host: "100.82.214.112",
lastSeenAt: Date(timeIntervalSince1970: 10),
isActive: false
),
],
],
blockedTeams: []
)
let selectedTeam = MutableTeamID("team-a")
let store = MobileShellComposite(
isSignedIn: true,
connectionState: .connected,
pairedMacStore: pairedStore,
identityProvider: StaticIdentityProvider(userID: "user-1"),
teamIDProvider: { await selectedTeam.value },
hiddenMacStore: hiddenStore
)
await store.loadPairedMacs()
let computer = try #require(store.pairedMacs.first)
let scope = try #require(await store.currentScopeSnapshot())
let scopeKey = store.pairedMacScopeKey(scope)
store.requestHideStoredPairedMacEntries(
representativeID: computer.id,
aliasIDs: [computer.id]
)
await hiddenStore.waitForDelayedHideSave()
let task = try #require(store.computerVisibilityMutationTasksByID[computer.id])
await selectedTeam.set("team-b")
store.currentTeamDidChange()
#expect(task.isCancelled)
#expect(store.computerVisibilityMutationIDs.isEmpty)
#expect(store.computerVisibilityMutationTasksByID[computer.id] != nil)
#expect(store.computerVisibilityMutationOperationIDsByID[computer.id] != nil)
await hiddenStore.releaseDelayedHideSave()
await task.value
#expect(store.computerVisibilityMutationTasksByID.isEmpty)
#expect(store.computerVisibilityMutationOperationIDsByID.isEmpty)
#expect(store.hiddenComputers.isEmpty)
#expect(await hiddenStore.load(scope: scopeKey).isEmpty)
}
@Test func rawDeviceIDMarkerMatchingExistingRowSurvivesMigration() async throws {
let defaultsSuiteName = "hidden-marker-hint-migration-\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: defaultsSuiteName))
@@ -174,13 +174,22 @@ import Testing
)
let store = connected.store
let workspaceID = try #require(store.workspaces.first?.id)
let scopedGroupID = MobileWorkspaceGroupPreview.ID(
rawValue: "test-mac\u{1F}group-a"
)
store.workspaceGroups = [
MobileWorkspaceGroupPreview(id: "group-a", name: "Before", anchorWorkspaceID: workspaceID),
MobileWorkspaceGroupPreview(
id: scopedGroupID,
remoteGroupID: "group-a",
macDeviceID: "test-mac",
name: "Before",
anchorWorkspaceID: workspaceID
),
]
connected.clock.advance(by: 2)
guard case .success = await store.renameWorkspaceGroup(id: "group-a", title: " yu ") else {
guard case .success = await store.renameWorkspaceGroup(id: scopedGroupID, title: " yu ") else {
return #expect(Bool(false), "same-account group rename should outlive the route ticket")
}
let requests = await connected.router.groupActions()
@@ -1,6 +1,7 @@
import CMUXMobileCore
import CmuxMobilePairedMac
import CmuxMobileRPC
import CmuxMobileShellModel
import Foundation
import Testing
@testable import CmuxMobileShell
@@ -541,51 +542,123 @@ import Testing
return store
}
@Test func tailscalePreferencePromotesGrantedRouteAheadOfIrohPin() throws {
@Test func tailscaleMethodUsesOnlyGrantedTailscaleRoute() throws {
let tailscale = try tailscale()
let routes = MobileShellComposite.storedReconnectRoutes(
[tailscale, try iroh()],
supportedKinds: [.iroh, .tailscale],
preferNonLoopback: true,
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
macDeviceID: "test-mac",
grantRoutes: [tailscale]
)
)
// The granted Tailscale destination dials first; Iroh stays as the
// fallback instead of being exclusive.
#expect(routes.map(\.kind) == [.tailscale, .iroh])
#expect(routes.map(\.kind) == [.tailscale])
}
@Test func tailscalePreferenceWithoutGrantKeepsIrohExclusivePin() throws {
// A preference flip alone grants nothing: without a device-local grant
// the Iroh pin still drops every raw host/port fallback.
@Test func tailscaleMethodWithoutGrantRejectsEveryRoute() throws {
let routes = MobileShellComposite.storedReconnectRoutes(
[try tailscale(), try iroh()],
supportedKinds: [.iroh, .tailscale],
preferNonLoopback: true,
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
macDeviceID: "test-mac",
grantRoutes: []
)
)
#expect(routes.map(\.kind) == [.iroh])
#expect(routes.isEmpty)
}
@Test func tailscalePreferenceIgnoresGrantForDifferentDestination() throws {
@Test func tailscaleMethodRejectsMismatchedGrantWithoutIrohFallback() throws {
let otherDestination = try tailscale(50907)
let routes = MobileShellComposite.storedReconnectRoutes(
[try tailscale(), try iroh()],
supportedKinds: [.iroh, .tailscale],
preferNonLoopback: true,
tailscalePreference: MobileShellComposite.TailscaleRoutePreference(
tailscaleRequirement: MobileShellComposite.TailscaleRouteRequirement(
macDeviceID: "test-mac",
grantRoutes: [otherDestination]
)
)
#expect(routes.map(\.kind) == [.iroh])
#expect(routes.isEmpty)
}
@Test func changingToUnavailableTailscaleDropsLiveIrohWithoutFallback() async throws {
let clock = TestClock()
let router = LivenessHostRouter()
// The factory boxes the live Iroh transport it hands out, so the test
// can observe physical teardown, not just the store's logical route.
let liveTransportBox = TransportBox()
let factory = KindRecordingTransportFactory(
router: router,
box: liveTransportBox,
failingKinds: [.tailscale]
)
let tailscale = try tailscale()
let iroh = try iroh()
let (pairedStore, directory) = try makePairedMacStore()
defer { try? FileManager.default.removeItem(at: directory) }
try await pairedStore.upsert(
macDeviceID: "test-mac",
displayName: "Test Mac",
routes: [tailscale, iroh],
instanceTag: "default",
markActive: true,
stackUserID: "user-1",
teamID: nil,
now: clock.now
)
try await pairedStore.authorizeUserTailscaleRoutes(
macDeviceID: "test-mac",
instanceTag: "default",
stackUserID: "user-1",
teamID: nil,
routes: [tailscale]
)
let methodDefaults = UserDefaults(
suiteName: "connection-method-live-switch-\(UUID().uuidString)"
)!
let methodStore = MobileConnectionMethodStore(defaults: methodDefaults)
let store = MobileShellComposite(
runtime: LivenessTestRuntime(
transportFactory: factory,
now: { clock.now },
supportedRouteKinds: [.iroh, .tailscale]
),
isSignedIn: true,
pairedMacStore: pairedStore,
connectionMethodStore: methodStore,
identityProvider: StaticIdentityProvider(userID: "user-1"),
reachability: AlwaysOnlineReachability(),
pairingHintDefaults: UserDefaults(
suiteName: "connection-method-pairing-hint-\(UUID().uuidString)"
)!,
hiddenMacStore: InMemoryPairedMacHiddenStore()
)
await store.loadPairedMacs()
#expect(await store.reconnectActiveMacIfAvailable(stackUserID: "user-1"))
#expect(store.activeRoute?.kind == .iroh)
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
methodStore.method = .tailscale
// `activeRoute == nil` only proves the store cleared its logical
// route; the dropped live Iroh transport must also finish closing so
// no physical cleanup work is still pending when the test completes.
let applied = try await pollUntil {
let liveTransportClosed =
await liveTransportBox.get()?.isClosedForTesting() == true
return factory.attemptedKinds().contains(.tailscale)
&& store.connectionState == .disconnected
&& store.activeRoute == nil
&& liveTransportClosed
}
#expect(applied)
#expect(store.activeRoute == nil)
#expect(factory.attemptedKinds().filter { $0 == .iroh }.count == 1)
}
}
@@ -1,10 +1,83 @@
import CmuxMobileRPC
import CmuxMobileShellModel
import Foundation
import Testing
@testable import CmuxMobileShell
@Suite struct TerminalRawInputOrderingTests {
@MainActor
@Test func returnKeyExposesCommandSendProgressAndSettlement() async throws {
let router = RoutingHostRouter()
await router.setHoldFirstTerminalInput(true)
let store = try await makeRoutingConnectedStore(router: router)
store.sendTerminalRawInput(
Data("\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)
await router.awaitFirstTerminalInputReached()
#expect(
store.terminalSendStatus(forTerminalID: RoutingHostRouter.terminalA)
== .sending
)
await router.releaseFirstTerminalInput()
#expect(await waitForTerminalSendStatus(
.sent,
store: store,
terminalID: RoutingHostRouter.terminalA
))
}
@MainActor
@Test func rejectedReturnKeyExposesCommandSendFailure() async throws {
let router = RoutingHostRouter()
await router.setRejectTerminalInput(at: 0)
let store = try await makeRoutingConnectedStore(router: router)
store.sendTerminalRawInput(
Data("\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)
#expect(await waitForTerminalSendStatus(
.failed,
store: store,
terminalID: RoutingHostRouter.terminalA
))
}
@MainActor
@Test func secondQueuedReturnOwnsItsFailureSettlement() async throws {
let router = RoutingHostRouter()
await router.setHoldFirstTerminalInput(true)
await router.setRejectTerminalInput(at: 1)
let store = try await makeRoutingConnectedStore(router: router)
store.sendTerminalRawInput(
Data("first\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)
await router.awaitFirstTerminalInputReached()
store.sendTerminalRawInput(
Data("second\r".utf8),
surfaceID: RoutingHostRouter.terminalA
)
await router.releaseFirstTerminalInput()
#expect(await waitForTerminalSendStatus(
.failed,
store: store,
terminalID: RoutingHostRouter.terminalA
))
#expect(
await router.recordedTerminalInputs().map(\.text)
== ["first\r", "second\r"]
)
}
@MainActor
@Test func orderedIrohFallbackPipelinesAtMostFourRequests() async throws {
let router = RoutingHostRouter()
@@ -475,6 +548,23 @@ import Testing
}
}
@MainActor
private func waitForTerminalSendStatus(
_ expected: MobileTerminalSendStatus,
store: MobileShellComposite,
terminalID: String
) async -> Bool {
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(2))
while clock.now < deadline {
if store.terminalSendStatus(forTerminalID: terminalID) == expected {
return true
}
await Task.yield()
}
return false
}
private actor RawInputBarrierTerminalLane: MobileTerminalLaneConnection {
private var pendingFrames: [MobileTerminalLaneOutputFrame] = []
private var receiveContinuation:
@@ -6,18 +6,18 @@ public enum MobileConnectionMethod: String, CaseIterable, Sendable {
/// Dial the built-in encrypted peer-to-peer transport (direct paths with
/// managed relays as fallback). The default; no setup required.
case automatic
/// Prefer the user's Tailscale network. Requires entering the Tailscale
/// pairing code shown on the Mac once, which authorizes that exact peer.
/// Require the user's Tailscale network. Requires entering the Tailscale
/// pairing code shown on the Mac once, which authorizes that exact peer;
/// Iroh is never used as a fallback while this method is selected.
case tailscale
}
/// Persists the user's connection-method choice.
///
/// The preference only reorders dialing: `tailscale` puts authorized Tailscale
/// routes ahead of the automatic transport instead of the default pin that
/// dials the automatic transport exclusively. It never manufactures Tailscale
/// authorization by itself; a pairing code entry remains the authorization
/// event for each Mac.
/// The choice is exclusive: `automatic` uses the built-in encrypted transport,
/// while `tailscale` dials only an authorized Tailscale route. It never
/// manufactures Tailscale authorization by itself; a pairing code entry remains
/// the authorization event for each Mac.
///
/// The backing `UserDefaults` is injected so the store is testable without
/// touching `.standard`; the app constructs it at the composition root.
@@ -29,12 +29,17 @@ public final class MobileConnectionMethodStore {
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
@ObservationIgnored private var continuations:
[UUID: AsyncStream<MobileConnectionMethod>.Continuation] = [:]
/// The user's current connection-method choice.
public var method: MobileConnectionMethod {
didSet {
guard method != oldValue else { return }
defaults.set(method.rawValue, forKey: Self.methodKey)
for continuation in continuations.values {
continuation.yield(method)
}
}
}
@@ -48,4 +53,22 @@ public final class MobileConnectionMethodStore {
self.method = .automatic
}
}
/// Observes connection-method changes, beginning with the current method.
///
/// Each subscriber owns an independent stream. Cancelling iteration removes
/// that subscriber without affecting Settings or other connection owners.
public func changes() -> AsyncStream<MobileConnectionMethod> {
let id = UUID()
let current = method
return AsyncStream { continuation in
continuations[id] = continuation
continuation.yield(current)
continuation.onTermination = { [weak self] _ in
Task { @MainActor in
self?.continuations[id] = nil
}
}
}
}
}
@@ -1,4 +1,4 @@
import Foundation
public import Foundation
/// A coalescing, back-pressured queue of pending terminal input.
///
@@ -19,6 +19,8 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
public var terminalID: MobileTerminalPreview.ID
/// The accumulated text for this chunk.
public var text: String
/// The newest Return-terminated send represented by this chunk.
public var sendStatusOperationID: UUID?
/// Creates a pending-input chunk.
/// - Parameters:
@@ -28,11 +30,13 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
public init(
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID,
text: String
text: String,
sendStatusOperationID: UUID? = nil
) {
self.workspaceID = workspaceID
self.terminalID = terminalID
self.text = text
self.sendStatusOperationID = sendStatusOperationID
}
}
@@ -56,7 +60,8 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
public mutating func enqueue(
_ text: String,
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID
terminalID: MobileTerminalPreview.ID,
sendStatusOperationID: UUID? = nil
) -> MobileTerminalInputEnqueueResult {
guard !text.isEmpty else { return .queued }
let byteCount = text.utf8.count
@@ -67,13 +72,17 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
last.workspaceID == workspaceID,
last.terminalID == terminalID {
last.text += text
if let sendStatusOperationID {
last.sendStatusOperationID = sendStatusOperationID
}
pendingChunks[pendingChunks.count - 1] = last
} else {
pendingChunks.append(
Chunk(
workspaceID: workspaceID,
terminalID: terminalID,
text: text
text: text,
sendStatusOperationID: sendStatusOperationID
)
)
}
@@ -120,7 +129,10 @@ public struct MobileTerminalInputSendBuffer: Equatable, Sendable {
return Chunk(
workspaceID: pendingChunks[0].workspaceID,
terminalID: pendingChunks[0].terminalID,
text: prefix
text: prefix,
// Settle only after the final piece of a split chunk has been
// handed to the transport.
sendStatusOperationID: nil
)
}
let chunk = pendingChunks.removeFirst()
@@ -0,0 +1,11 @@
/// User-visible settlement state for a terminal submission.
///
/// The state is scoped per terminal and covers both the composer paste path and
/// Return-terminated raw terminal commands. Ordinary keystrokes are excluded so
/// typing does not flash transport chrome for every character.
public enum MobileTerminalSendStatus: Equatable, Sendable {
case idle
case sending
case sent
case failed
}
@@ -1,6 +1,6 @@
import Foundation
/// Pure derivations from the per-Mac state map to the flat, user-facing shapes.
/// Pure derivations from the per-Mac state map to aggregated workspace and group shapes.
///
public struct MobileWorkspaceAggregation: Sendable {
private let rowIDSeparator = "\u{1F}"
@@ -73,15 +73,36 @@ public struct MobileWorkspaceAggregation: Sendable {
)
}
/// Stable display id for one Mac-local group inside an aggregated list.
private func groupID(
macDeviceID: String,
instanceTag: String?,
groupID: MobileWorkspaceGroupPreview.ID
) -> MobileWorkspaceGroupPreview.ID {
guard let instanceTag, !instanceTag.isEmpty else {
return MobileWorkspaceGroupPreview.ID(
rawValue: "\(macDeviceID)\(rowIDSeparator)\(groupID.rawValue)"
)
}
return MobileWorkspaceGroupPreview.ID(
rawValue: "\(macDeviceID)\(rowIDSeparator)\(instanceTag)\(rowIDSeparator)\(groupID.rawValue)"
)
}
/// Derive the flat, ordered workspace list across all Macs.
public func derivedWorkspaces(
statesByMac: [String: MacWorkspaceState],
foregroundMacDeviceID: String?,
machineColorIndex: [String: Int]
machineColorIndex: [String: Int],
macIDsInDisplayOrder: [String]? = nil
) -> [MobileWorkspacePreview] {
let shouldScopeRowIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
let orderedMacIDs = macIDsInDisplayOrder ?? orderedMacIDs(
statesByMac: statesByMac,
foregroundMacDeviceID: foregroundMacDeviceID
)
var result: [MobileWorkspacePreview] = []
for macID in orderedMacIDs(statesByMac: statesByMac, foregroundMacDeviceID: foregroundMacDeviceID) {
for macID in orderedMacIDs {
guard let state = statesByMac[macID] else { continue }
for workspace in state.workspaces {
let ownerID = workspace.macDeviceID ?? state.macDeviceID
@@ -102,6 +123,13 @@ public struct MobileWorkspaceAggregation: Sendable {
instanceTag: stamped.macInstanceTag,
workspaceID: remoteID
)
if let remoteGroupID = workspace.groupID {
stamped.groupID = groupID(
macDeviceID: ownerID,
instanceTag: stamped.macInstanceTag,
groupID: remoteGroupID
)
}
}
result.append(stamped)
}
@@ -109,28 +137,54 @@ public struct MobileWorkspaceAggregation: Sendable {
return result
}
/// Derive the group sections to show for the foreground Mac.
/// Derive group sections from every Mac in the same order as workspaces.
///
/// Group ids are Mac-local, so a multi-Mac list namespaces both group ids
/// and anchor workspace ids. The original group id remains available through
/// ``MobileWorkspaceGroupPreview/rpcGroupID`` for mutations.
public func derivedGroups(
statesByMac: [String: MacWorkspaceState],
foregroundMacDeviceID: String?
foregroundMacDeviceID: String?,
macIDsInDisplayOrder: [String]? = nil
) -> [MobileWorkspaceGroupPreview] {
guard let foregroundMacDeviceID, let state = statesByMac[foregroundMacDeviceID] else { return [] }
let shouldScopeRowIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
guard shouldScopeRowIDs, !foregroundMacDeviceID.isEmpty else { return state.groups }
let remoteIDByLocalID = Dictionary(
uniqueKeysWithValues: state.workspaces.map { workspace in
(workspace.id, workspace.remoteWorkspaceID ?? workspace.id)
}
let shouldScopeIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
let orderedMacIDs = macIDsInDisplayOrder ?? orderedMacIDs(
statesByMac: statesByMac,
foregroundMacDeviceID: foregroundMacDeviceID
)
return state.groups.map { group in
var scoped = group
let remoteID = remoteIDByLocalID[group.anchorWorkspaceID] ?? group.anchorWorkspaceID
scoped.anchorWorkspaceID = rowID(
macDeviceID: state.macDeviceID,
instanceTag: state.instanceTag,
workspaceID: remoteID
var result: [MobileWorkspaceGroupPreview] = []
for macID in orderedMacIDs {
guard let state = statesByMac[macID] else { continue }
let remoteWorkspaceIDByLocalID = Dictionary(
uniqueKeysWithValues: state.workspaces.map { workspace in
(workspace.id, workspace.remoteWorkspaceID ?? workspace.id)
}
)
return scoped
for group in state.groups {
let remoteGroupID = group.remoteGroupID ?? group.id
var stamped = group
stamped.remoteGroupID = shouldScopeIDs ? remoteGroupID : group.remoteGroupID
stamped.macDeviceID = state.macDeviceID
stamped.macInstanceTag = state.instanceTag
guard shouldScopeIDs, !state.macDeviceID.isEmpty else {
result.append(stamped)
continue
}
stamped.id = groupID(
macDeviceID: state.macDeviceID,
instanceTag: state.instanceTag,
groupID: remoteGroupID
)
let remoteAnchorID = remoteWorkspaceIDByLocalID[group.anchorWorkspaceID]
?? group.anchorWorkspaceID
stamped.anchorWorkspaceID = rowID(
macDeviceID: state.macDeviceID,
instanceTag: state.instanceTag,
workspaceID: remoteAnchorID
)
result.append(stamped)
}
}
return result
}
}
@@ -18,17 +18,18 @@ public import Foundation
/// ```swift
/// var store = MobileWorkspaceGroupCollapseStore(defaults: .standard)
/// let shown = store.apply(to: groupsFromMac) // seeds unknown groups, applies local
/// store.set(groupID, collapsed: true) // device-local, not sent to the Mac
/// store.set(shown[0].collapseStateID, collapsed: true) // device-local, not sent to Mac
/// ```
public struct MobileWorkspaceGroupCollapseStore: Sendable {
/// The defaults key under which the `[groupID: collapsed]` map is stored.
/// The defaults key under which the `[collapseStateID: collapsed]` map is stored.
public static let defaultsKey = "dev.cmux.mobile.workspaceGroup.collapse.v1"
// UserDefaults is Apple-documented thread-safe; OK to hold nonisolated.
private nonisolated(unsafe) let defaults: UserDefaults
/// groupID.rawValue -> this device's collapse decision. The map doubles as the
/// "have I seen this group?" set: a present key means the group's collapse is
/// device-owned; an absent key means it still inherits the Mac's seed.
/// group.collapseStateID -> this device's collapse decision. The map doubles
/// as the "have I seen this group?" set: a present key means the group's
/// collapse is device-owned; an absent key means it still inherits the Mac's
/// seed.
private var map: [String: Bool]
/// Create a store backed by the given defaults.
@@ -62,24 +63,36 @@ public struct MobileWorkspaceGroupCollapseStore: Sendable {
/// `isCollapsed` with it; otherwise seed the device decision from the Mac's
/// reported value (initial inheritance) and keep that. Entries for groups no
/// longer present are dropped so the map stays bounded by the live group count.
/// - Parameter groups: The groups as reported by the Mac.
/// - Parameter groups: The owner-stamped groups derived from every Mac.
/// - Returns: The same groups with `isCollapsed` reflecting this device.
public mutating func apply(to groups: [MobileWorkspaceGroupPreview]) -> [MobileWorkspaceGroupPreview] {
let liveIDs = Set(groups.map(\.id.rawValue))
var changed = false
// Prune decisions for groups that no longer exist (renamed-away/deleted),
// keeping the map bounded by the number of live groups.
for key in map.keys where !liveIDs.contains(key) {
map.removeValue(forKey: key)
changed = true
let liveIDs = Set(groups.map(\.collapseStateID))
let groupsByRemoteID = Dictionary(grouping: groups, by: { $0.rpcGroupID.rawValue })
let legacyMigrationTargetByRemoteID: [String: String] = groupsByRemoteID.compactMapValues { matchingGroups in
guard matchingGroups.count == 1,
let group = matchingGroups.first,
let macDeviceID = group.macDeviceID,
!macDeviceID.isEmpty,
group.collapseStateID != group.rpcGroupID.rawValue else {
return nil
}
return group.collapseStateID
}
var changed = false
let resolved = groups.map { group -> MobileWorkspaceGroupPreview in
var group = group
let key = group.id.rawValue
let key = group.collapseStateID
if let local = map[key] {
group.isCollapsed = local
} else if legacyMigrationTargetByRemoteID[group.rpcGroupID.rawValue] == key,
let legacyLocal = map[group.rpcGroupID.rawValue] {
// Before groups carried Mac ownership, collapse preferences were
// keyed only by the raw group id. Migrate only when one live,
// authoritatively owned group can claim that legacy decision.
map[key] = legacyLocal
group.isCollapsed = legacyLocal
changed = true
} else {
// First time this device sees the group: inherit the Mac's value.
map[key] = group.isCollapsed
@@ -88,6 +101,14 @@ public struct MobileWorkspaceGroupCollapseStore: Sendable {
return group
}
// Prune decisions for groups that no longer exist (renamed-away/deleted),
// keeping the map bounded by the number of live groups. This runs after
// legacy migration so old raw-id decisions are still available above.
for key in map.keys where !liveIDs.contains(key) {
map.removeValue(forKey: key)
changed = true
}
if changed { persist() }
return resolved
}
@@ -29,6 +29,13 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
/// The group's stable identifier.
public var id: ID
/// The Mac-local group identifier when ``id`` is namespaced for an
/// aggregated multi-Mac list. `nil` means ``id`` is already Mac-local.
public var remoteGroupID: ID?
/// The stable device identifier of the Mac that owns this group.
public var macDeviceID: String?
/// The owning cmux app instance tag, when the Mac has multiple builds.
public var macInstanceTag: String?
/// The group's user-facing name, shown as the section header label.
public var name: String
/// Whether the group is currently collapsed (members hidden, header shown).
@@ -41,9 +48,33 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
/// header and never rendered as a separate row.
public var anchorWorkspaceID: MobileWorkspacePreview.ID
/// The group identifier to send back to the owning Mac.
public var rpcGroupID: ID {
remoteGroupID ?? id
}
/// Stable key for this phone's device-local collapse preference.
///
/// Group identifiers are Mac-local. Namespacing the persistence key by the
/// owner prevents two Macs with the same raw group id from sharing collapse
/// state, and keeps the preference stable when another Mac joins or leaves
/// the aggregated list.
public var collapseStateID: String {
guard let macDeviceID, !macDeviceID.isEmpty else {
return rpcGroupID.rawValue
}
guard let macInstanceTag, !macInstanceTag.isEmpty else {
return "\(macDeviceID)\u{1F}\(rpcGroupID.rawValue)"
}
return "\(macDeviceID)\u{1F}\(macInstanceTag)\u{1F}\(rpcGroupID.rawValue)"
}
/// Creates a workspace group preview.
/// - Parameters:
/// - id: The group's stable identifier.
/// - remoteGroupID: The Mac-local id when `id` is aggregate-namespaced.
/// - macDeviceID: The stable device id of the owning Mac.
/// - macInstanceTag: The owning cmux app instance tag, when present.
/// - name: The group's user-facing name.
/// - isCollapsed: Whether the group is collapsed. Defaults to `false`.
/// - isPinned: Whether the group is pinned. Defaults to `false`.
@@ -51,6 +82,9 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
/// - anchorWorkspaceID: The anchor workspace that owns the group.
public init(
id: ID,
remoteGroupID: ID? = nil,
macDeviceID: String? = nil,
macInstanceTag: String? = nil,
name: String,
isCollapsed: Bool = false,
isPinned: Bool = false,
@@ -58,6 +92,9 @@ public struct MobileWorkspaceGroupPreview: Identifiable, Equatable, Sendable {
anchorWorkspaceID: MobileWorkspacePreview.ID
) {
self.id = id
self.remoteGroupID = remoteGroupID
self.macDeviceID = macDeviceID
self.macInstanceTag = macInstanceTag
self.name = name
self.isCollapsed = isCollapsed
self.isPinned = isPinned
@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import CmuxMobileShellModel
@@ -105,4 +106,29 @@ import Testing
#expect(buffer.pendingByteCount == 0)
#expect(buffer.nextBatch(maximumByteCount: 4) == nil)
}
@Test func coalescedChunkRetainsNewestSendOperation() {
var buffer = MobileTerminalInputSendBuffer()
let workspaceID = MobileWorkspacePreview.ID(rawValue: "workspace-a")
let terminalID = MobileTerminalPreview.ID(rawValue: "terminal-a")
let firstOperationID = UUID()
let secondOperationID = UUID()
#expect(buffer.enqueue(
"first\r",
workspaceID: workspaceID,
terminalID: terminalID,
sendStatusOperationID: firstOperationID
) == .startDraining)
#expect(buffer.enqueue(
"second\r",
workspaceID: workspaceID,
terminalID: terminalID,
sendStatusOperationID: secondOperationID
) == .queued)
let batch = buffer.nextBatch()
#expect(batch?.text == "first\rsecond\r")
#expect(batch?.sendStatusOperationID == secondOperationID)
}
}

Some files were not shown because too many files have changed in this diff Show More