| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
Address CodeRabbit on the combined diff: normalize argv[0], honor .ignore cwd in snapshot - AgentResumeArgv.commandParts: normalize arguments.first so a whitespace-only argv[0] falls back to the default executable instead of emitting an invalid one. - restorableWorkingDirectory: a custom registration with cwd: .ignore now yields nil workingDirectory in the snapshot (it resumes from the current dir), so downstream restore consumers don't place the terminal in a saved cwd and break the cwd-policy contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
Organize SPM packages into Packages/{Shared,iOS,macOS}/ so the folder tree mirrors the workspace groups (#6278) Every Swift package now lives physically under exactly one group directory (Packages/Shared, Packages/iOS, Packages/macOS), so the repo's directory tree and the root workspace's group columns are the same shape. Opening cmux.xcworkspace shows each package under the Shared / iOS / macOS group matching the folder it lives in. Folder is the source of truth. Group = which app(s) consume the package: both apps -> Shared, iOS app only -> iOS, macOS app only -> macOS. check-workspace-package-groups.py mirrors the folders directly; --write regenerates the workspace, --check (in CI, beside check-pbxproj) fails on drift. All boundary-crossing relative paths were rewritten to keep the build intact: inter-package deps same group `../Name` / cross group `../../<Group>/Name`; escaping paths gain one level (vendor `../../../vendor/...`, GhosttyKit `../../../GhosttyKit.xcframework`); macOS project relativePaths, ios/cmuxPackage and Examples deps + project relativePaths, the file-length budget, the iOS conventions lint scopes, the namespace-type baseline, the test-ios change globs, the ci.yml per-package `swift test` loop (now resolves the group dir), and doc/skill references all updated to the nested paths. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
iOS workspace list: groups, unread dots, last-activity previews, shared Unread filter (#5726) * iOS: render workspace groups as collapsible sections The iOS workspace list flattened workspaces and ignored the desktop's named, collapsible groups. The mobile workspace-list payload did not carry any group structure, so surface it on the Mac host, then render it on iOS. Mac host: - mobileWorkspacePayload now emits group_id per workspace; the list adds a top-level groups array (id, name, is_collapsed, is_pinned, anchor_workspace_id, member_workspace_ids). The all-windows branch aggregates each window's groups in window-iteration order. - MobileWorkspaceListObserver now subscribes to $workspaceGroups and folds group order/name/collapse/pin/anchor + per-workspace groupId into its summary hash, so a collapse/expand (or rename/move) re-emits workspace.updated to the phone. Without this the phone's disclosure would look frozen. - Expose workspace.group.collapse/expand to mobile (mobileHostHandleRPC + the ticket-auth model), gated by the same same-account Stack auth as the rest of the data plane. Advertise a workspace.groups.v1 capability. iOS: - Decode optional group_id per workspace + an optional groups array (backward compatible with older Macs). - MobileWorkspaceGroupPreview value model; MobileWorkspaceListItem builds the ordered render items mirroring SidebarWorkspaceRenderItem (anchor renders as the header, no separate row; collapsed hides members; ungrouped interleave by position). - WorkspaceGroupHeaderRow: chevron toggles collapse; tapping the name selects/opens the anchor workspace (so the anchor stays reachable), mirroring the desktop header. - Store carries workspaceGroups + supportsWorkspaceGroups and a fire-and-forget setWorkspaceGroupCollapsed RPC (authoritative re-fetch, no optimistic state). The grouped section renders only when the Mac advertises the capability and the user is not searching; otherwise the list stays flat (pinned-first), preserving member contiguity. P1 is display + expand/collapse. Phone-side group create/rename/restructure is deferred. New strings localized (en + ja). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS groups: observe per-workspace groupId so membership moves re-emit The mobile workspace-list summary hash includes each workspace's groupId, but the observer never subscribed to workspace.$groupId. Moving a workspace into or out of an existing group mutates only that workspace's groupId (not the tab set, workspaceGroups, panels, title, or pin state), so emitIfNeeded never fired and paired phones kept rendering stale group membership until some unrelated change woke the observer. Add workspace.$groupId to the per-workspace publisher set and cover a pure membership move in MobileWorkspaceListFidelityTests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Gate grouped rendering on the groups payload, not host.status Review feedback (Cursor, Greptile): grouped sections rendered only once mobile.host.status returned and advertised workspace.groups.v1, so a slow or failed status fetch kept the list flat even though the workspace list already carried groups. A Mac that emits groups also handles workspace.group.collapse/expand (they shipped together), so render from the payload and derive the toggle closure from capability-or-payload. The passive chevron (no toggle) no longer carries an .isButton accessibility trait it cannot honor, and the render-item builder documents the non-contiguous-member degradation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split mobile list payloads and actions out of length-budgeted files The groups payload pushed TerminalController.swift (+66), MobileShellComposite.swift (+49), and MobileHostService.swift (+11) over the Swift file length budget. Move the mobile workspace-list payload builders and the mobile group collapse handler into TerminalController+MobileWorkspaceList.swift, the fire-and-forget workspace mutations into MobileShellComposite+WorkspaceActions.swift, and the advertised capability list into MobileHostService+Capabilities.swift, then tighten the three budget entries to the new actuals. Also resolves group membership with a single pass over tabs instead of one scan per group (review feedback) via mobileWorkspaceGroupPayloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: iMessage-style last-activity preview per workspace row The phone's workspace rows showed only the first terminal's name as the subtitle and an empty trailing slot. Surface the Mac's last activity per workspace so the list reads like a messages app: a one-line preview of the most recent notification under the title, and a compact relative time (now / 2m / 1h / 3d, month-day past a week) in the trailing slot. Mac host: - mobileWorkspacePayload emits preview + preview_at per workspace, sourced from the same latestNotification(forTabId:) the Mac sidebar subtitle uses, so phone and desktop mirror each other. - mobilePreviewSanitize flattens notification text to one plain-text line: ANSI/OSC escapes stripped, control chars and newlines collapsed, whitespace runs joined, capped at 140 chars with an ellipsis. A noisy notification can never bloat the list payload or wrap the row. - MobileWorkspaceListObserver now takes the notification store and subscribes to $notifications, folding a per-workspace signature of the latest notification (id + timestamp) into its summary hash. A new notification (or clearing one) changes nothing in the TabManager graph, so without this the phone's previews would go stale until an unrelated change woke the observer. iOS: - Decode optional preview/preview_at (absent on older Macs). - previewLine prefers the Mac's preview, falling back to the first terminal's name as before; timestampOrStatus shows the relative activity time when connected. - New string localized (en + ja). Tests: decode coverage for present/absent preview fields, observer-hash coverage for preview-signature changes, and sanitize coverage for ANSI stripping, whitespace flattening, and the length cap. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound mobile preview sanitizing and strip truncated escape sequences Notification bodies come from terminal output with no ingestion length cap, and mobilePreviewSanitize runs on the main actor for every workspace on every mobile list refresh. Cap the scanned input at 16x the visible preview length before the regex and scalar passes so a multi-megabyte notification costs the same as a small one, and make each escape-sequence alternation also match an unterminated sequence at end-of-input so a sequence cut by the cap is stripped instead of leaking payload bytes. A truncated input always renders a trailing ellipsis. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deterministic compact relative time that refreshes every minute Review feedback (Codex, Greptile): the row's relative activity label was a plain String computed during body evaluation, so a quiet workspace could show "now" indefinitely, and the 1m-7d branch used Date.FormatStyle.relative, which always reads the system clock and ignored the injected now, making the documented test determinism false. Bucketing now lives in CmuxMobileShellModel as MobileRelativeActivity, a pure value computation from the injected reference instant (covered by package tests), and the UI maps buckets to localized compact strings (now / 2m / 1h / 3d, month-day past a week; en + ja). WorkspaceRow wraps the label in TimelineView(.everyMinute) and threads the timeline date through, so the label advances on its own at the same minute granularity it renders. Also rewords the observer's notification-store comment that stated the ownership backwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Red test: preview input bound must hold in unicode scalars, not Characters A single crafted grapheme cluster carrying a huge run of combining marks is one Character, so the Character-counted input cap never truncates it: the whole cluster is scanned by the regex and scalar passes on the main-actor list path and leaks into the sanitized preview. This test asserts the output stays scalar-bounded; it fails until the bound is moved to the scalar view. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Bound preview sanitizing in unicode scalars, not Characters Autoreview P1: the input cap counted Characters, but one crafted grapheme cluster can hide an arbitrarily long run of combining scalars inside a single Character, so index(_:offsetBy:limitedBy:), the escape regex, and the scalar pass still scanned (and emitted) the whole cluster on the main-actor list path. Walk the unicodeScalars view instead: each step advances one scalar, so the bound costs at most the cap regardless of cluster shape, and the output stays scalar-bounded. Scalar-view indices are valid String slice bounds; a mid-cluster cut at worst yields a degenerate cluster the later passes treat as text. Verified standalone: the combining bomb previously leaked 17,921 scalars into the result, now 2,241 (cap + ellipsis), with all prior sanitize behaviors unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Red test: every mobile workspace row must carry last_activity_at Some phone rows showed no relative time because the only timestamp in the mobile workspace payload was preview_at, sourced from the latest notification. A workspace that never fired a notification (a quiet or freshly created one) carried no timestamp at all, so its trailing slot stayed empty. This test pins the producer contract: every workspace payload carries last_activity_at, falling back to the workspace's creation time when there is no notification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mobile list: stamp last_activity_at on every row + emit has_unread Root cause of the missing relative times: the payload's only timestamp was preview_at, sourced from latestNotification(forTabId:), so a workspace that never fired a notification carried no timestamp and its trailing slot stayed empty on the phone. Every workspace payload now carries last_activity_at: the latest notification's createdAt when there is one, falling back to the workspace's creation/restore stamp (new Workspace.createdAt) otherwise. The payload also emits has_unread, mirroring the same workspaceIsUnread(forTabId:) that drives the Mac sidebar's workspace badge (notification unread + manual/panel-derived/restored indicators), so the phone can render an iMessage-style unread dot. The observer folds the unread flag into its per-workspace signature and additionally subscribes to the three workspace-level unread indicator sets (manual, panel-derived, restored), which live outside both the TabManager graph and the notifications array, so an unread toggle pushes workspace.updated to the phone. The signature builder is now a static, store-injected function so tests cover the flip directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: decode last_activity_at + has_unread into the workspace preview Adds the optional last_activity_at and has_unread fields to the workspace.list decoder and maps them onto MobileWorkspacePreview (lastActivityAt: Date?, hasUnread: Bool, defaulting to read). The row's relative time now prefers the every-row last_activity_at stamp, falling back to preview_at for Macs that emit previews but predate the stamp, so quiet workspaces finally show how long ago they were touched. Decode + mapping covered for present and absent fields (older Macs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: iMessage-style unread dot + configurable 1/2-line preview One shared unread component, one shared row layout. WorkspaceUnreadDot is the single unread indicator: an accent-color dot in a fixed-width gutter LEFT of the workspace icon, rendered by every row (hidden when read) so read and unread rows keep their columns aligned. It lives inside the shared WorkspaceRow, which both the flat workspace list and the device tree's workspace leaves render via WorkspaceNavigationRow, so tree mode inherits the dot, the relative time, and the preview without reimplementing anything. VoiceOver hears "Unread" through the row's combined accessibility summary instead of the decorative dot. The activity preview under the title now shows two lines by default (space reserved, so rows stay uniform like Messages) with a new Display > Preview Lines setting (1 or 2) on the existing MobileDisplaySettings mechanism, persisted, clamped, and threaded through the list as a value snapshot like wrapWorkspaceTitles. New strings localized (en + ja). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Shared workspace-list filter model (Unread) MobileWorkspaceListFilter is one predicate shared by every surface that lists workspaces: an enum (all / unread) with a matches(_:) arm per case, so future filters are new cases rather than per-surface ad-hoc closures. The device tree's filter menu builds on it next; the flat list can adopt the same type later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: shared Unread filter on both list surfaces + tree display parity One WorkspaceListFilterMenu / WorkspaceListFilterEmptyRow pair (shared controls over the MobileWorkspaceListFilter model) now drives the flat workspace list and the device tree; tree workspace leaves also adopt the shared display settings (title wrap, preview line count) so they render identically to flat-list rows. * Group headers carry aggregate unread state (anchor + collapsed members) In grouped mode the anchor never renders as its own row and collapsed members are hidden, so unread activity in either place had no visible indicator. Mirror the Mac sidebar header badge: the header shows the anchor's unread state while expanded and the whole group's (anchor included) while collapsed, rendered with the shared WorkspaceUnreadDot and announced to VoiceOver via the existing mobile.workspace.unread string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Policy: file-scope clamp helper, split filter empty row into own file cmux-policy findings: pure helpers should be file-scope private funcs rather than private statics, and new files should hold one major type. Moves clampedWorkspacePreviewLineCount to file scope (@MainActor, since the range constant lives on the @MainActor settings class) and gives WorkspaceListFilterEmptyRow its own file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document the run-loop ordering invariant for @Published sinks Review flagged that the $notifications sink reads derived store state that is rebuilt in didSet while @Published emits from willSet. Verified empirically that throttle(for:scheduler: RunLoop.main) always hops through the run loop, so sinks observe post-didSet state; record that invariant where the subscriptions read the store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Red test: colon and <=> CSI parameter bytes must strip from previews ECMA-48 CSI parameter bytes are 0x30-0x3F, but the preview sanitizer only consumes [0-9;?]. 24-bit color SGR (ESC[38:2::255:0:0m) and private-use parameters (ESC[>4;2m) leak their tails into the mobile preview. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Strip the full ECMA-48 CSI parameter byte range from previews The sanitizer's CSI alternation only consumed [0-9;?] parameter bytes, so colon-parameter SGR (ESC[38:2::255:0:0m) and private-use <=> parameters left their sequence tails visible in the mobile preview. Widen the class to the full 0x30-0x3F parameter range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Sign-out wipes workspace groups; filter empty state defers to search Review findings: workspaceGroups is account-scoped like pairedMacs and registryDevices, so signOut() now clears it alongside the placeholder workspace reset (plain disconnect still keeps groups with the last-known offline snapshot; full list responses replace both wholesale). The Unread-filter empty-state row no longer claims responsibility while a search query is active, since Show All cannot resolve an empty search. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope preview-line clamp helper onto MobileDisplaySettings The package-conventions linter (scripts/lint-ios-package-conventions.sh) forbids file-scope free functions in the mobile packages, so the cmux-policy suggestion to make this a file-scope helper broke package-conventions-lint. Restore it as a private static member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Refresh Workspace.swift length budget after rebase onto main Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> | 3 个月前 | |
Organize SPM packages into Packages/{Shared,iOS,macOS}/ so the folder tree mirrors the workspace groups (#6278) Every Swift package now lives physically under exactly one group directory (Packages/Shared, Packages/iOS, Packages/macOS), so the repo's directory tree and the root workspace's group columns are the same shape. Opening cmux.xcworkspace shows each package under the Shared / iOS / macOS group matching the folder it lives in. Folder is the source of truth. Group = which app(s) consume the package: both apps -> Shared, iOS app only -> iOS, macOS app only -> macOS. check-workspace-package-groups.py mirrors the folders directly; --write regenerates the workspace, --check (in CI, beside check-pbxproj) fails on drift. All boundary-crossing relative paths were rewritten to keep the build intact: inter-package deps same group `../Name` / cross group `../../<Group>/Name`; escaping paths gain one level (vendor `../../../vendor/...`, GhosttyKit `../../../GhosttyKit.xcframework`); macOS project relativePaths, ios/cmuxPackage and Examples deps + project relativePaths, the file-length budget, the iOS conventions lint scopes, the namespace-type baseline, the test-ios change globs, the ci.yml per-package `swift test` loop (now resolves the group dir), and doc/skill references all updated to the nested paths. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
iOS: don't lose saved hosts/IPs on upgrade (paired-Mac backup + restore) (#6405) * ios: failing test — paired-Mac store strands data on future schema version Adds the paired-Mac backup/restore design doc and a red regression test: when an older build opens a paired-macs.sqlite3 whose user_version was bumped by a newer build, the store currently throws unknownSchemaVersion and every read fails, surfacing as total loss of the user's saved hosts even though the rows are still on disk. The fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: don't strand the paired-Mac store on a newer on-disk schema version runMigrations threw unknownSchemaVersion when user_version exceeded this build's, failing ensureReady and every read — so a user who upgraded (future schema vN) and then ran an older build saw all saved hosts as gone, though the rows were intact. Schema migrations are additive by contract, so older builds can still read the columns/tables they know. Degrade gracefully: log and read existing rows, never reset user_version (no destructive downgrade marker). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: per-user pairedMacs backup collection (server) Adds the first client-owned sync collection. The phone backs up its local saved-host list (including manually typed host/IPs, which live only on-device today) so it survives an app upgrade, bundle-id change, or reinstall. - New POST /v1/sync/paired-macs route → DO RPC backupPairedMacs(teamId, userId, ops), mirroring the trusted heartbeat RPC rather than expanding the live WS inbound surface. - Per-user privacy scoping by physical collection name pairedMacs:<userId> (userId is verified, never client input); outgoing frames are relabeled to the logical `pairedMacs` so the client never sees the suffix. Reuses the whole generic snapshot/delta/tombstone/GC machinery unchanged. - Subscribe forwards the verified x-presence-user-id; the DO pins it on the WS attachment and serves/broadcasts pairedMacs scoped to that user. - Per-user record cap, op bounds, route byte budget (mirrors heartbeat). - bun tests: parse bounds, per-user isolation, cap, relabel, tombstone, no-op idempotency. Full suite 144 pass; typecheck + wrangler dry-run clean. Additive and live-safe: new collection keys only, no class migration, old DO instances ignore the new RPC/collection during rollout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: GET /v1/sync/paired-macs restore path Adds the read side of the per-user backup: DO RPC listPairedMacs returns the live (non-tombstone) saved-host records newest-first, served by GET on the same authenticated, user-scoped route. The phone fetches this on sign-in to restore saved hosts after a reinstall or bundle-id change. Decouples restore from the WS sync client (which is built but not yet wired into the live app). bun test for list ordering + per-user isolation; strict test typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: shape-aware equality for pairedMacs (no rev churn on timestamp drift) A backup upsert whose routes/name/active are unchanged but whose lastSeenAt advanced (every route refresh, and every full reconcile push on sign-in) must not re-mint a rev or broadcast a delta. Compare list-shape only, ignoring timestamps, mirroring the device-list collection. Stored lastSeenAt then tracks the last shape change (correct as-of-rev semantics for restore ordering). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: paired-Mac backup uploader + restore-on-sign-in Wires the iOS side of saved-host durability behind the mobilePairedMacBackup flag (DEBUG-on/Release-off, env/UserDefaults overridable): - PairedMacBackupClient: HTTP client for /v1/sync/paired-macs (POST ops, GET restore), auth mirrors PresenceClient/DeviceRegistryService. - BackingUpPairedMacStore: a MobilePairedMacStoring decorator so EVERY paired-Mac mutation flows through one seam — upsert/remove mirror to the DO best-effort (local stays authoritative); the sign-out wipe (removeAll) is NOT mirrored so the server backup survives for the next sign-in. - PairedMacRestore: on the first signed-in read, merge the backup into the local store — LWW by lastSeenAt (never clobber a newer local edit), insert missing hosts, and honor the backup's active host only when local has none (fresh install), so restore never hijacks the device's current active selection. - Composition root wraps the local store with the decorator when the flag is on and a presence URL resolves. Restore goes over HTTP (GET) rather than the WS sync client, which is built but not yet wired into the live app, so this feature is self-contained. No new user-facing strings (silent background backup/restore). swift test: 7 new tests pass (decorator mirroring, restore LWW/active rules, flag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: make PairedMacRestore an injectable struct (package conventions) The iOS package-conventions lint forbids caseless enums with only static members (namespace-enum/namespace-type). Convert PairedMacRestore to a struct that takes the store + backup as injected dependencies with an instance run(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: address review — restore memoization, retry, team scope, setActive mirror Fixes from Cursor Bugbot / CodeRabbit / Greptile on the backup decorator: - removeAll (sign-out wipe) now resets the restore memo, so a same-launch re-sign-in restores again instead of returning an empty list (this was the exact sign-out→sign-in path; it was silently broken). - fetchAll returns nil on transport/auth failure (vs [] for genuinely empty), and restore is memoized only on a successful fetch — a transient first-launch failure now retries on the next read instead of stranding restore until restart. - Restore is scoped per (account, team), not per account: the backup DO is per-team, so switching teams re-restores (teamIDProvider injected). - Concurrent first reads share one in-flight restore Task, so a second read can't slip past the memo and observe a half-merged store. - setActive now mirrors the affected account scope to the DO (accurate records read back from the local store), so "select a host without connecting, then reinstall" no longer restores a stale active host. markActive upserts mirror the scope too, preserving the single-active invariant in the backup. - remove only mirrors a delete while signed in (no auth-failing noise for anonymous removals). - Migration test asserts user_version is left untouched (no downgrade marker). swift test: 11 backup + 5 migration tests pass; package-conventions lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): auto-publish this Mac's route to the user's pairedMacs backup DEV-only convenience so a fresh dev iOS build never needs a manual host entry. MacPairedMacBackupPublisher (DEBUG-on, env/UserDefaults overridable) registers the iOS-pairing-listener default on (so an attach route exists without toggling a setting), observes MobileHostService.statusUpdates(), and POSTs this Mac's deviceId+displayName+routes (active) to /v1/sync/paired-macs whenever routes change and the user is signed in. Routes are encoded via CmxAttachRoute so the iOS restore decodes them identically. Best-effort and Release-noop, mirroring PresenceHeartbeatClient. Bridges the dev gap where the registry (localhost) and presence devices projection don't deliver the Mac's route to the dev iOS build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): default iOS-pairing listener ON in DEBUG; drop runtime register The dev self-publisher needs the pairing listener bound so an attach route exists. Registering a UserDefaults fallback at runtime was clobbered by the settings runtime registering the catalog default, so move the default to the source: MobileCatalogSection.iOSPairingHost defaults true in DEBUG, false in Release (an explicit user toggle still wins). Release behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac: wire MacPairedMacBackupPublisher.swift into cmux.xcodeproj The new file was never added to the Xcode project, so it didn't compile, the AppDelegate reference was an undefined symbol, and every macOS build failed (reload-cloud kept the stale binary; CI would fail too). Add the four pbxproj entries (PBXBuildFile + PBXFileReference + Cloud group + app-target Sources phase), mirroring PresenceHeartbeatClient.swift. Verified: the dev Mac now auto-publishes its route to the user's pairedMacs backup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh AppDelegate Swift file-length budget for the publisher wiring The one-line MacPairedMacBackupPublisher.shared.configure(auth:) call (+ its comment) at the composition root grew AppDelegate.swift by 4 lines, tripping the file-length budget guard. Accept the minor known debt: the wiring belongs next to the other client configures. 17593 -> 17597. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: surface restored saved Macs on the disconnected screen Restoring saved Macs into the local store wasn't visible: the disconnected screen only auto-reconnected and otherwise jumped straight to "add device", so a restored Mac (e.g. on a fresh dev build, or when auto-reconnect can't reach it) never showed. Now the disconnected screen loads saved Macs (which also triggers the backup restore) and lists them for one-tap reconnect, only auto-presenting the pairing sheet when there are none to pick. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tapping a saved Mac dialed the phone's own loopback instead of Tailscale A restored/published Mac advertises both a debug_loopback route (127.0.0.1, priority 0) and a tailscale route. DEBUG builds keep .debugLoopback in supportedRouteKinds even on a physical device (for the on-device XCUITest mock host), so firstReconnectHostPortRoute, which picks the lowest-priority supported route, chose 127.0.0.1 — the phone's own loopback — and the connect silently failed without ever trying Tailscale. That made tapping a saved/restored Mac (switchToMac) and stored-Mac reconnect not connect on a device. Fix in route selection, not supportedKinds (XCUITests still need loopback): add preferNonLoopback (true on physical devices, false on the simulator where 127.0.0.1 IS the Mac). When set, a real route always wins over a .debugLoopback route regardless of priority; loopback is used only when it's the sole supported route. Tests cover device-prefers-tailscale, device-loopback-only fallback, and simulator-keeps-loopback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P1): tag workspaces with their Mac (macDeviceID) Foundation for the aggregated multi-Mac workspace list + machine filtering. Adds macDeviceID to MobileWorkspacePreview (additive, defaulted) and stamps it from the connected Mac's ticket where the workspace list is built. Invisible today (single Mac), but every workspace now records which Mac it's from, which P3 (aggregation) and P4 (group/filter by machine) build on. Design in plans/feat-ios-multi-mac-workspaces/DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4a): compound workspace filter (read-state × machine) Replaces the single-dimension All/Unread filter enum with a composable struct: readState (all/unread) × machines (Set<macDeviceID>, empty = all), passing both only when a row satisfies both. Expresses "unread on Mac X and Mac Y" directly. The filter menu gains a machine multi-select section that appears once more than one machine is present (single-Mac users see the unchanged All/Unread control); the list views compile unchanged since .all/.matches/.isActive/.emptyStateText are preserved on the struct. en+ja localized. 6 model tests incl. the compound case. Machine names are wired in once aggregation (P3) provides multiple Macs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4b): machine-list derivation + prune for the filter Pure, tested helpers the filter UI and aggregation need: machineIDs(in:) gives the distinct machines present in a workspace list (first-appearance order, skips unknown-machine rows) to populate the filter's machine multi-select, and pruneMachines(notIn:) drops selections for machines that vanished so a stale machine filter never silently hides everything. Full model suite 52 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P2): per-Mac connection pool foundation Introduces MacConnection {macDeviceID, ticket, route, client, generation} and a connections:[macDeviceID:MacConnection] pool + foregroundMacDeviceID on the composite. The foreground attach now records its entry in the pool and teardown clears it. Additive and behavior-preserving (single-Mac == a pool of one); anonymous (empty-id) tickets are not pooled. This is the structure P3 builds on to open read-only connections to the user's other Macs and aggregate their workspaces. Compiles; route + backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3a): read-only secondary-Mac workspace fetch fetchSecondaryWorkspaceList(for:) opens a short-lived client to another paired Mac (reusing the manualHostTicket + workspace.list path, loopback-deprioritized on device) and returns its workspaces tagged with that Mac's macDeviceID, never touching the foreground connection. refreshSecondaryMacWorkspaces() populates secondaryWorkspacesByMac for every signed-in non-foreground Mac. Additive: not yet merged into the published list, so the single-Mac flow is untouched. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3b): merge other Macs' workspaces into the list (flag-gated) Foreground connect now kicks a background refreshSecondaryMacWorkspaces(), and publishAggregatedWorkspaces() merges the other Macs' rows after the foreground Mac's (de-duped by id, per-Mac order preserved). Gated by multiMacAggregation (env/UserDefaults, DEBUG on / Release off) and a no-op when there are no secondaries, so the single-Mac list is byte-for-byte unchanged. Cleared on teardown. Compiles; route/backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4): surface the machine multi-select in the filter WorkspaceListView derives the machines present in the (aggregated) workspace list and passes them to the filter menu, so the read-state × machine compound filter's machine section appears once more than one Mac has workspaces. Names come from the device tree; single-Mac shows the unchanged All/Unread control. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P5): cross-Mac open switches the foreground connection openWorkspace now detects when the tapped workspace belongs to a Mac other than the current foreground connection (aggregated list) and switches the foreground to that Mac before selecting, so the terminal attaches to the right Mac. Gated by multiMacAggregation; no-op for single-Mac. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh MobileShellComposite file-length budget for multi-Mac code The P2-P5 multi-Mac connection pool + aggregation + cross-Mac open added ~196 lines to MobileShellComposite.swift. The methods call private connect/ticket helpers so they can't move to a separate-file extension; accept the known debt. 5566 -> 5762. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh paired-Mac routes from backup before multi-Mac aggregation The aggregated multi-Mac workspace list only showed the foreground Mac's workspaces because secondary Macs' stored routes went stale: refreshSecondary- MacWorkspaces read the local paired-Mac store, but that store is only restored from the backup once per launch (memoized scope). When a secondary Mac relaunched on a new port and republished its route to the per-user backup, the iPhone never re-read it, so the read-only workspace fetch dialed a dead port and that Mac silently dropped out of the list. Fix: add PairedMacBackupRefreshing.refreshFromBackup(stackUserID:) on BackingUpPairedMacStore, which forces a backup re-fetch + LWW merge (bypassing the once-per-launch memo, coalescing with any in-flight restore). refreshSecondary- MacWorkspaces calls it before loadAll, so secondary routes are current before the fetch. LWW by lastSeenAt means the live foreground route is never clobbered. Principled: routes are kept fresh from the authoritative per-user backup at aggregation time, instead of relying on a single sign-in-time restore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect first reachable Mac so home opens on the integrated list The home fell back to the "Your Macs" picker whenever there was no Mac marked active (or the active Mac's stored route was stale), forcing a manual tap before any workspaces showed. Rework the launch auto-connect so the home comes up connected to all Macs and shows one integrated list, without the picker: - Refresh saved-Mac routes from the per-user backup before dialing (LWW), so a Mac that relaunched on a new port is still reachable instead of failing to the picker. - Connect the explicitly-active Mac when reachable, otherwise the FIRST saved Mac with a usable route, instead of bailing when nothing is marked active. The other Macs are aggregated read-only (refreshSecondaryMacWorkspaces) into the same list, so the home is one integrated cross-Mac workspace list. The picker now only appears as the genuinely-offline fallback (no saved Mac has a usable route). Principled: auto-connect targets any reachable saved Mac with fresh routes, rather than depending on a single persisted "active" selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh routes from backup before manual Mac switch too switchToMac dialed the in-memory snapshot's routes, so manually switching to a Mac that had relaunched on a new port could fail on a stale route. Apply the same backup-refresh used by auto-connect and aggregation: refresh the per-user backup, re-read the target from the store, then dial its fresh route (falling back to the snapshot if the re-read yields nothing). Completes route-freshness across every saved-Mac connect path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: prefer IP-literal routes over MagicDNS hostnames for Mac connect/aggregation The multi-Mac aggregated list showed only the foreground Mac because the read-only secondary fetch to another Mac timed out. Root cause (confirmed on device via console diagnostics): a Mac can advertise three attach routes — debug_loopback, a MagicDNS hostname (e.g. <node>.<tailnet>.ts.net), and the raw tailscale IP. firstReconnectHostPortRoute picked the first non-loopback route, which was the MagicDNS hostname. MagicDNS doesn't resolve on every client (the phone here), so the attach-ticket request to the hostname timed out and that Mac was silently dropped from the aggregated list. A Mac that only advertises an IP route (no hostname) connected fine, which is why one Mac showed and the other didn't. Fix: among non-loopback routes, prefer one whose host is a numeric IP literal (IPv4/IPv6) over a hostname, since an IP is dialable without DNS. Falls back to a hostname route when no IP route exists, and loopback only as last resort. firstReconnectHostPortRoute is the shared selector for reconnect, manual switch, and secondary aggregation, so this fixes tap-to-connect to hostname-route Macs too. Added isIPLiteralHost + 3 route-selection tests incl. the exact repro. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: re-aggregate other Macs on pull-to-refresh and app foreground The aggregated multi-Mac list fetched each other Mac's workspaces once, on foreground attach. Workspaces created on a secondary Mac afterwards never appeared, because the read-only secondary list is a snapshot, not a live subscription (only the foreground Mac streams workspace.updated). Re-run refreshSecondaryMacWorkspaces from the two natural refresh points: - refreshWorkspaces() (pull-to-refresh) now re-aggregates after reloading the foreground list. - resumeForegroundRefresh() (app returns to foreground) re-aggregates when connected, so switching back to the app surfaces newly-created remote workspaces without a manual pull. Both gated on multiMacAggregationEnabled + an active foreground connection, so single-Mac behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: add transport-agnostic per-Mac workspace state + pure derivation Foundation for deriving the aggregated multi-Mac workspace list from a single source of truth instead of imperatively merging a live foreground list with stale secondary snapshots. MacWorkspaceState is the phone's view of ONE Mac's workspaces (workspaces + groups + liveness), keyed by macDeviceID, carrying NO transport/connection detail. MobileWorkspaceAggregation derives the flat ordered de-duplicated list (foreground first, then by display name) and the group sections as pure functions of [macID: MacWorkspaceState]. Same model + derivation whether each entry is fed by N direct phone->Mac connections (now) or one phone->Durable Object stream delivering per-Mac deltas (planned), so that migration is a transport swap, not a data-model change. 6 derivation tests. Not yet wired into the composite (next commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: derive the workspace list from per-Mac state (slice 2) Wire the transport-agnostic data structure in: workspacesByMac is now the only stored workspace state, and `workspaces`/`workspaceGroups` are materialized derivations (private(set), assigned only by recomputeDerivedWorkspaceState). The foreground sync stream, secondary fetch, optimistic create-workspace/ terminal, preview, and reset all write per-Mac entries; the derived list recomputes via didSet. Anonymous/manual-ticket foreground uses a sentinel key. Deletes the two-sources-of-truth machinery: publishAggregatedWorkspaces (the re-merge band-aid) and secondaryWorkspacesByMac (the snapshot store). The foreground-update-overwrites-then-re-merges race is gone by construction: each Mac owns its entry, the aggregate is a pure function of them. clearRemoteConnection- Context keeps the offline foreground entry and drops only secondaries. Tests: 56 model+composite tests green (incl. new derivation + create/terminal/ preview paths). Test seam setWorkspacesForTesting replaces direct workspaces assignment. The 6 remaining failures are the pre-existing flaky render-grid timing tests, unchanged by this commit. Next (slice 3): per-Mac live workspace subscriptions feed workspacesByMac so remote-created workspaces appear with no refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: live per-Mac workspace subscriptions (slice 3) Each non-foreground Mac now holds a persistent read-only connection with its own live workspace.updated subscription that re-fetches its list on each change and writes its workspacesByMac entry, so a workspace created on another Mac appears with no pull-to-refresh. The derived list recomputes automatically. SecondaryMacSubscription holds the client + a fresh per-connection stream id + the consumer Task. refreshSecondaryMacWorkspaces is now an idempotent reconciler: establish a subscription for each newly-present secondary Mac, drop ones that disappeared or became the foreground. Fully best-effort and additive: any failure (no route, ticket/connect error, stream end) tears that entry down and the pull-to-refresh / foreground re-aggregate path remains the fallback, so a secondary subscription can never crash or block the foreground. Subscriptions are torn down on disconnect/sign-out (teardownSecondaryMacSubscriptions in clearRemoteConnectionContext). This is the N-persistent-connections model approved for now; the same per-Mac entries would later be fed by one phone->Durable Object stream (transport swap, no data-model change). 62 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: never show the Your Macs picker when Macs are saved The auto-connect made the home connect, but the root still fell back to the DisconnectedWorkspaceShellView picker whenever the foreground was not yet connected (initial connect window, or a failed/slow reconnect). Eliminate that: show the integrated workspace list whenever there are saved Macs, auto-connecting in the background, and only show the add-device flow when there are NO saved Macs at all. The list renders whatever has aggregated (foreground + live secondary subscriptions) and its toolbar carries settings/devices/sign-out, so nothing is lost by dropping the picker. Opening a workspace attaches its Mac on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect falls through to the next Mac when one is offline reconnectActiveMacIfAvailable picked a single target (active Mac, else first with a route) and connected once; if that Mac had a stored route but was actually down, the connect failed and the home showed "Mac offline" without trying any other reachable Mac. Build an ordered candidate list (active first, then every other Mac with a usable route) and try each via connectManualHost until one connects, so a single offline Mac never blocks the others. The restoring-gate deadline still caps the UI; the loop keeps trying in the background. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview findings (active-mac deactivation, stale secondary refresh, backup body cap) P1: PairedMacRestore deactivated the currently-active Mac when refreshFromBackup brought a fresher record for it (route refresh before reconnect/aggregation), losing the user's selection. Preserve the existing local active flag when updating an existing record; only honor the backup's active for records missing locally on a fresh install. Regression test added. P2: refreshSecondaryMacWorkspaces (foreground/pull) skipped Macs that already had a subscription, so a suspended/never-pushing secondary stream left a stale snapshot forever. Explicit refresh now reseeds existing secondary clients (and recreates dead ones), so a pull/foreground always updates the aggregate. P2: the paired-Mac backup POST reused the 16 KiB heartbeat cap while accepting up to 200 ops x 2 KiB routes, so legitimate backups 413'd and the best-effort client silently dropped them, staleing the server backup. readBoundedJson now takes a maxBytes; the backup route uses MAX_PAIRED_MAC_BACKUP_BYTES sized to the declared limits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 2 (sign-out restore race, stale machine filter blanks list) P1: BackingUpPairedMacStore.removeAll (sign-out wipe) cleared the inFlight map but did not cancel the restore tasks, so a backup fetch suspended across the wipe could resume and re-upsert the previous account's Macs into the emptied local store (privacy boundary). removeAll now cancels in-flight restores, and PairedMacRestore.run checks Task.isCancelled after its fetch and skips all writes. Regression test added. P2: the machine filter was never pruned, so when a filtered Mac left the aggregated list (a secondary disconnected, or fewer than two machines so the filter menu's machine section hid) the stale machine id rejected every row and stranded the user on a blank list with no visible control to clear it. This is the likely "blank black screen" after reconnect churn. WorkspaceListView now prunes filter.machines whenever the present machine set changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-machine avatar color + fix autoreview round 3 (wrong foreground key, secondary refetch storm, offline dead-end) Feature: workspaces from the same Mac now share one avatar color in the aggregated list (MachineAvatarPalette, keyed to macDeviceID with a workspace-id fallback and djb2 spread); the symbol still encodes terminal count. Unit-tested. P1: applyRemoteWorkspaceList wrote the foreground Mac's workspaces under the PREVIOUS foreground key because foregroundMacDeviceID was assigned after the apply. On a Mac A->B switch this stored B's list under A's key and the derived list went stale/empty once the id flipped. Set foregroundMacDeviceID before applying. P1: every secondary workspace.updated push awaited a full workspace.list with no coalescing, so a title/progress churn stream queued repeated full scans and MainActor aggregate updates. Added a per-Mac leading+trailing coalesced refresh (SecondaryMacSubscription.refreshTask/refreshPending) — bounded, no cancel/restart starvation. P2: an offline returning user whose auto-reconnect failed fell through to a workspace list whose only affordance (pull-to-refresh) no-ops while disconnected, with no reconnect control — a dead end. Added store.reconnectOrRefresh (reconnect when offline, refresh when connected), wired pull-to-refresh to it, and added a Reconnect button to the offline status row (localized en/ja). Keeps the integrated list as the only surface — no picker screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 4 (sign-out aggregation race, backup freshness on republish) P1: refreshSecondaryMacWorkspaces captured the account then awaited backup refresh, store load, client creation, and per-Mac fetches before mutating secondaryMacSubscriptions/workspacesByMac, while callers launched it in untracked Tasks. An in-flight pass could resume after sign-out/account switch and write the previous user's Macs/workspaces into the new UI. Added an isAggregationScopeValid guard (signed-in + same account + not cancelled) re-checked after every await before any mutation/connection, routed the pass through a tracked secondaryAggregationTask, and cancel it (plus tear down live secondary subscriptions) on sign-out and full reset. P1: a same-shape backup republish (Mac re-confirming its current live route) no-op'd without advancing the stored lastSeenAt, so the iOS LWW restore skipped the backup and kept dialing a stale local route. upsertRecord gained an opt-in freshnessOf; the paired-Mac path now refreshes lastSeenAt in place (same rev, no delta/broadcast) so restore sees the republish as fresh. Test extended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 5 (unscoped aggregation read, restore-memo race, unbounded paired-Mac tombstones) P1: refreshSecondaryMacWorkspaces allowed a nil/empty account, so loadAll(stackUserID: nil) would read EVERY locally stored Mac across Stack accounts and could publish another account's workspaces into the UI. Now requires a concrete signed-in user before any load/connection (mirrors loadPairedMacs), keeping the post-await scope checks. P1: per-user paired-Mac delete tombstones were never garbage-collected — the alarm only GC'd the devices collection — so an authenticated client churning create/delete grew synced:/synctomb: storage without bound (the live-record cap resets on delete). Added listTombstonedCollections; the alarm now GCs every per-user pairedMacs:<userId> collection that holds tombstones and folds each next-GC deadline into its schedule. P2: a restore suspended at `await task.value` across a sign-out wipe could resume and re-insert restoredScopes (or clobber a post-wipe inFlight entry), making a same-launch re-sign-in skip the backup restore and show an empty list. Added a resetGeneration bumped by removeAll; both restore paths bail if it changed across the await. Tests: paired-Mac tombstone discovery+GC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 6 (restore cancellation per-await, switchToMac stale cache) P1: PairedMacRestore checked Task.isCancelled only once after fetchAll, so a sign-out wipe landing during loadAll or any later upsert let the loop reinsert the previous account's Macs into the wiped store. Now re-checks after the load and before every write, bailing with completed: false. P1: switchToMac hard-failed unless the target was in the in-memory pairedMacs cache, but the multi-Mac aggregation reads Macs straight from the store, so tapping a freshly-restored secondary Mac's workspace no-op'd and stranded the user on a workspace whose Mac never connected. switchToMac now resolves the target from the store (after the backup refresh), falling back to the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: redesign devices screen as Computers management view (no connect step) Since workspaces from every Mac now appear together automatically, the device tree's "connect to a device" step is obsolete. Replace it with a Computers screen that manages the Macs signed in to the account: - One row per computer: machine-colored avatar (same color its workspaces use in the list, via the new shared MachineAvatarColors), name, online/last-seen status from durable-object presence, and workspace count. - Remove a computer via swipe or context menu (confirmed) -> forgetMac. - Add a computer via a toolbar + that opens the existing pairing flow (showAddDevice plumbed root -> shell -> list -> screen). - Drop the instance/tag/workspace expansion tree and Connect affordances; delete the now-dead DeviceTreeExpansionStore (+ tests) and the unused tree row snapshots, keeping only DeviceTreePresence. - New mobile.computers.* strings localized en + ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 7 (Release preview compile break, ineffective computer remove) P1: rootContent referenced WorkspaceListLayoutPreviewView directly, but that type is compiled only under `os(iOS) && DEBUG`, so a Release/iOS archive failed to type-check the branch ("cannot find ... in scope"). Added a DEBUG-wrapped workspaceListLayoutPreview helper (mirroring terminalLayoutPreview) so Release never names the gated type. P2: the Computers list was built from deviceTreeDevices (prefers the team registry), but Remove calls forgetMac, which only deletes the local paired-Mac backup row — so a registry-backed computer reappeared on the next registry load and Remove looked broken. Build the list from pairedMacs instead: this feature's source of truth, the same set that feeds the workspace aggregation and the exact rows forgetMac removes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: track CMUXMobileRootView in swift file-length budget (preview helper) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 8 (restore not cancelled on sign-out, idle-team tombstone GC, stale secondary rows) P1: signOut() never cancelled in-flight paired-Mac restores (it does not call removeAll), so the cancellation guards could not fire on the normal sign-out path; a restore suspended at its backup fetch could resume — possibly authorized with the next account's live token — and write rows for the previous account. Added PairedMacBackupRefreshing.cancelInFlightRestores (cancel tasks + bump reset generation, without wiping the per-user rows) and call it from signOut. P1: backupPairedMacs created delete tombstones but never scheduled an alarm, so an idle team (no presence instances/subscribers) would never wake to GC them and a create/delete churn grew DO storage unbounded. It now schedules the next tombstone-GC deadline for the user's collection after applying ops. P2: when a secondary Mac's event stream ended, the subscription was removed but its workspacesByMac entry stayed marked connected, leaving dead rows in the aggregate that taps routed into. The stream-end teardown now downgrades that Mac's state to unavailable so the rows show offline until a refresh re-establishes it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 9 (forget leaves secondary subscription, failed switch still opens workspace) P1: forgetMac removed the store row but, for a SECONDARY Mac, left its live read-only subscription and workspacesByMac entry intact, so the Computers screen's Remove left the forgotten Mac's workspaces in the list (still updating, tappable) until a later aggregation pass. forgetMac now cancels secondaryMacSubscriptions and clears workspacesByMac for that Mac. P2: openWorkspace awaited switchToMac for a cross-Mac workspace but selected the workspace even when the switch failed (no route / failed connect / fell back to the previous Mac), focusing a workspace whose Mac is not the live connection so terminal input targeted the wrong client. switchToMac now returns whether the foreground connection targets that Mac; openWorkspace bails (leaving the user on the list, with the Reconnect affordance) when it does not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: pop the compact stack when a cross-Mac workspace open fails (autoreview round 10 P1) The tap selects a workspace and pushes its detail synchronously, and openWorkspace runs from that detail's task — so an early return on a failed switchToMac left the user inside a workspace whose Mac never became the foreground connection (terminal input would route to the wrong live client). On switch failure, roll the selection back (selectedWorkspaceID = nil) so the compact stack pops to the list, where the offline row's Reconnect / next aggregation pass recovers the Mac. Known follow-ups (autoreview round 10, narrow edges not on the dogfood path): - sign-out-during-restore cancellation is fire-and-forget; the residual race needs the restore fetch bound to the captured account/team in the backup client. - an empty-macDeviceID QR connect keeps the foreground under the anonymous key and does not migrate workspacesByMac/foregroundMacDeviceID when the real id is adopted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: color computers by distinct position, not a colliding hash (fix two Macs both yellow) The avatar color hashed macDeviceID into 8 slots, so two Macs collided on one color ~1/8 of the time — Lawrence's two real device ids both hashed to slot 2 (yellow). Assign a DISTINCT color index per Mac by sorted device id in the aggregation (MobileWorkspaceAggregation.machineColorIndex), stamp it onto each derived workspace (machineColorIndex), and color the Computers rows from the same store map. Different Macs are now guaranteed distinct up to the palette size; the id hash remains only as a fallback outside the aggregated list. Tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: promote the live secondary connection on cross-Mac open instead of re-dialing (root-cause fix for "Mac offline") Architectural fix. Tapping a secondary Mac's workspace ran openWorkspace -> switchToMac -> connectManualHost -> connect(), which threw away the already-live, authorized read-only client in secondaryMacSubscriptions and re-dialed the foreground from scratch. That re-dial pipeline has several independent failure points — route re-derivation via refreshFromBackup LWW, the offline preflight, and connect()'s connectionGeneration supersession race — any of which strands the user as "Mac offline" even though a working client to that exact Mac exists. switchToMac now first tries promoteSecondaryToForeground: probe the live secondary client, and on success take ownership of it as the foreground connection (reuse the client/route/ticket, start terminal polling, re-aggregate the demoted Mac) with no re-dial. Falls back to the existing re-dial only when no live connection exists. This makes "offline on a reachable, already-aggregated Mac" unrepresentable. First cut of the larger unification (one MacConnection per Mac, foreground as a selector); the write-only `connections` pool and the duplicate connect path collapse in the follow-up. Orthogonal dev-only gap remains: a secondary on an ephemeral port the phone can't refresh (no dev registry) has no live connection to promote. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: Computers screen — drive the dot from the phone's real connection, show presence + route as diagnostics, refresh live while open The connection dot mixed two sources: the phone's live RPC status for the foreground Mac, but the Durable Object presence worker (the Mac's own heartbeat, not the phone's connection) for every other Mac. So a Mac the phone is actively connected to as a SECONDARY showed not-green because presence (unreliable on dev) didn't report it — exactly the "MacBook Pro not green" case. Now the dot is driven by the phone's own per-Mac connection (store.macConnectionStatuses, derived from each MacWorkspaceState.status: green=connected foreground/secondary, orange=reconnecting, grey=not connected), which updates reactively as subscriptions connect/drop. Presence and the dialable route (host:port) move to a separate diagnostic line, so a mismatch — "online via presence but the phone can't connect" — is a visible tailscale/route signal, and the user can see the exact endpoint. While the sheet is open it re-aggregates every 4s so a dropped Mac reconnects quickly. New strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tap a computer for a comprehensive detail/debug sheet Tapping a row on the Computers screen now pushes MacComputerDetailView with the full per-Mac picture, separated so a connection problem is diagnosable: - Connection: the PHONE's live status to this Mac + workspace count + whether it is the active foreground. - Presence (from the Durable Object presence worker): online/offline + last seen, or "unknown", with a footer explaining that presence is the Mac's heartbeat, not the phone's connection, and that online-but-not-connected = a Tailscale/route problem. - Routes the phone can dial: every saved route (kind + host:port), selectable. - Identity: device id, paired-since, route-updated. - Actions: Reconnect, Remove. Rows are NavigationLinks into the sheet; strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-Mac custom name, color, and icon — synced across the user's devices Users can rename a computer and give it a custom color (8 swatches or any color) and icon (curated SF Symbols or any emoji) from the computer's detail sheet. The override wins over the Mac-reported name and the automatic color/icon everywhere: the workspace list avatars, the Computers screen, and the detail sheet. Persistence + sync reuse the existing per-user Durable Object paired-Mac backup: - MobilePairedMac + customName/customColor/customIcon; SQLite store v2 migration (additive nullable columns) + setCustomization (preserves the Mac's reported name/routes/active, bumps lastSeenAt for LWW). - PairedMacBackupRecord (Swift + worker) carries the fields; parse + bounds + pairedMacShapeEqual treat them as shape so a change mints a rev and broadcasts. - BackingUpPairedMacStore uploads the COMPLETE current record on every write (so a route refresh never clobbers a customization) and mirrors setCustomization. - PairedMacRestore applies the fields (LWW) so an edit on device A appears on B. - store.updateMacCustomization persists + uploads + re-derives; the aggregation stamps custom color/icon onto each workspace preview. Color is "palette:<n>" or "#RRGGBB"; icon is an SF Symbol name or an emoji (classified by non-ASCII). Strings localized en/ja. Tests: worker customization sync + shape; restore-applies + setCustomization-preserves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: show a Reconnecting/Reconnect overlay on the terminal when disconnected (fix recurring "black screen") Recurring report: the phone drops its connection (dev route staleness with no registry to refresh) and the workspace detail keeps showing the now-dead terminal surface — an unrendered black screen with only a tiny status pill. The connection is fine to re-establish, but nothing tells the user that or offers an action. WorkspaceDetailView now overlays the terminal with TerminalDisconnectedOverlay whenever macConnectionStatus != .connected: a spinner for .reconnecting, and an offline icon + host + a Reconnect button (-> store.reconnectOrRefresh) for .unavailable. So a dropped connection reads as "Reconnecting…" with a clear action instead of a black void. Localized (reuses mobile.workspace.reconnect). Note: the underlying dev route-refresh gap (a secondary Mac on an ephemeral port the phone can't relearn without the registry) still requires a re-pair on dev; this makes that state visible + recoverable instead of silent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: add wrangler.dev.toml for safe cmux-presence-dev deploys Deploying the dev instance with `wrangler deploy --name cmux-presence-dev` inherits the production presence.cmux.dev custom domain from wrangler.toml (--name only overrides the worker name), STEALING the prod domain from cmux-presence and breaking prod auth (the dev worker uses the dev Stack project). Add a dedicated wrangler.dev.toml (workers_dev = true, no custom domain) so the dev instance stays on its *.workers.dev URL, and point the README at it with a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: encode a per-developer isolated-worker pattern for concurrent dev Problem: cmux-presence-dev is a SINGLE shared worker — last deploy wins, and an unmerged feature (the paired-Mac backup lives only on its branch) exists only on whoever deployed last, so two people working on the worker clobber each other. Pattern: each developer deploys their own cmux-presence-dev-<slug> via scripts/deploy-dev.sh. Each named worker has its OWN Durable Object namespace, so presence + paired-Mac-backup state is fully isolated per dev — any number of people dogfood worker changes at once without collision. Builds point at it via CMUX_PRESENCE_BASE_URL; the shared cmux-presence-dev stays the integration baseline (the script refuses reserved/prod names). To make a tapped iOS DEVICE build honor the override (it sees no shell env), the resolver now also reads an Info.plist key CMUXPresenceBaseURL — precedence env → UserDefaults → Info.plist → Debug default (tested). README documents the full pattern + guardrails; the remaining wiring (reload baking CMUXPresenceBaseURL into the tagged Info.plist next to CMUXDevTag) is flagged as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: track PresenceServiceConfiguration in swift file-length budget Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden paired-Mac v2 migration + bound Computers-screen polling Autoreview findings on the multi-Mac PR: [P1] v2 SQLite migration was neither idempotent nor atomic: it ran the three ADD COLUMN statements then bumped user_version separately, so a kill / disk-full / SQLite error after a partial apply stranded the DB at v1 with some v2 columns present. The next launch re-ran ADD COLUMN custom_name and failed with a duplicate-column error, bricking the paired-Mac store. Now each migration step runs inside one transaction (SQLite DDL + PRAGMA user_version are both transactional, so a partial apply rolls back and retries cleanly), and migrateToV2 only adds columns missing from PRAGMA table_info, which also recovers any dogfood device already left half-migrated by the earlier build. Adds a regression test that seeds a partially-applied v2 schema and asserts recovery. [P2] The Computers sheet polled store.reconnectOrRefresh() every 4s while open, which pulled the DO backup over the network and, when disconnected, re-dialed offline Macs on a fixed timer (battery/network fan-out). The online dots (presence) and secondary workspace lists are already push-driven, so the timer now calls a bounded refreshComputersScreen() (local row reload + coalesced foreground refresh only) on a gentler 10s cadence and leaves offline-Mac dialing to presence-push recovery and the explicit pull-to-refresh / per-Mac Reconnect button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Route multi-Mac workspace mutations + reconnect to the owning Mac Round 2 autoreview findings on the multi-Mac aggregation: [P1] promoteSecondaryToForeground reused a live secondary connection as the new foreground but never started its terminal event stream: it called cancelRemoteOperationTasks() (which does NOT clear terminalEventListenerTask/ ID) and then startTerminalRefreshPolling(), which no-ops while a listener task is still installed. The promoted client got no terminal/workspace/ notification push events, so output stalled until another path restarted the stream. Now stop+start the listener (the existing == listenerID defer guard makes the old listener's async teardown safe). [P2] Aggregated workspace rows can belong to a secondary Mac, but rename/pin/ unread/close all sent to the single foreground remoteClient — wrong Mac, and with a colliding id could mutate a foreground workspace. sendWorkspaceMutation now resolves the workspace's owning Mac (workspaceMutationTarget) and routes to that Mac's client: foreground -> remoteClient + refreshWorkspaces(); a live secondary -> its client + scheduleSecondaryRefresh(); a known offline owner -> no send + snap back (never misroute to foreground). A failed secondary write no longer marks the foreground connection unavailable. [P2] The per-computer detail Reconnect button called reconnectOrRefresh() (foreground/active Mac) and ignored the computer being viewed. It now calls switchToMac(macDeviceID:), which promotes a live secondary to this Mac or re-dials it specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix foreground-Mac ownership: stale rows, switch fast path, test double Round 3 autoreview findings: [P1] Adding setCustomization to MobilePairedMacStoring broke the CmuxSyncStore test target: FakePairedStore conformed with only the old methods. Added a no-op setCustomization to the fake. [P1] On a foreground Mac change (connect A->B, promotion, or a real connect after an anonymous/sign-out session) the previous foreground/anonymous entry was left in workspacesByMac. recomputeDerivedWorkspaceState derives over every entry, so stale rows kept showing and could route actions/opens through stale ownership (regressing the old workspaces = remoteWorkspaces full replacement). Added dropStalePreviousForeground(): on the foreground flip it removes only the old foreground key (never a live/offline secondary, which aggregation re-adds), wired into both the connect path and promoteSecondaryToForeground. [P1] switchToMac's already-foreground fast path trusted the persisted isActive flag, which lags the live connection (promoteSecondaryToForeground writes it via an unawaited Task; stale during reconnect/switch races). It could return success without switching and leave input/mutations on the wrong Mac. Now gates on the live foregroundMacDeviceID == macDeviceID identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix offline-Mac dot + bake iOS presence override; doc team-scope limit Round 4 autoreview findings: [P2] clearRemoteConnectionContext set the global status unavailable but left the retained offline foreground entry in workspacesByMac with status .connected. macConnectionStatuses (the Computers screen's per-Mac dots) derives from those per-Mac states, so a just-disconnected Mac kept showing a green connected dot. Now downgrade the retained entry to .unavailable. [P2] The CMUXPresenceBaseURL Info.plist override was read by PresenceServiceConfiguration but never baked, so a tapped dev device build ignored a per-developer isolated worker. Wired the bake end to end: added the CMUX_PRESENCE_BASE_URL build setting to ios/Config/Shared.xcconfig (empty default) + the CMUXPresenceBaseURL key in ios/Config/Info.plist, and ios/scripts/reload.sh now passes $CMUX_PRESENCE_BASE_URL at both xcodebuild sites (next to CMUX_DEV_TAG). Release/TestFlight stay empty -> unaffected. Updated the worker README (no longer a TODO). [P2] Documented the per-(account, team) backup vs account-scoped local rows scope gap inline at mirrorAccountScope. Solo/single-team users are unaffected; proper multi-team isolation needs a team_id store column (v3 migration), tracked as a follow-up rather than expanding this upgrade-safety PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bound Computers timer, key manual reconnects by real id, stop backup clobber Round 5 autoreview findings: [P1] refreshComputersScreen() (the open-sheet 10s timer) delegated to refreshWorkspaces(), which fans out refreshSecondaryMacWorkspaces() to every saved Mac and re-establishes/re-dials missing (offline) subscriptions — the reconnect storm the screen is meant to avoid (my earlier bounding fix was incomplete). It now does a foreground-only reload (riding any in-flight pull-to-refresh) and never initiates the secondary fan-out; recovery stays on presence-push + explicit pull/Reconnect. [P1] A Mac without mobile.attach_ticket.create connects via a synthetic manual-<host>:<port> ticket, and connect() keyed foreground state by ticket.macDeviceID. So a switch/reconnect to such a Mac stamped foreground workspaces with the synthetic id; filters, Computers rows, mutation routing, and aggregation no longer recognized the real Mac as foreground (and could open a duplicate secondary). connect()/connectManualHost now take the real pairedMacDeviceID hint (threaded from switchToMac, reconnect, device-row paths) and key foreground state + the connection pool under it. [P2] The Mac route-publisher omits customName/color/icon, but the worker treated absent fields as part of the record shape, so every Mac heartbeat minted a rev that wiped the user's iOS-set customizations and the next restore cleared them. Fix: iOS uploads now ALWAYS emit the three custom keys (null = reset-to-Auto, authoritative) via a custom encoder; the worker preserves stored customizations for any key an upload OMITS (the Mac), while a present key (iOS) still sets/clears it. Tests on both sides. (Dev worker needs redeploy for dogfood.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stamp foreground rows with real Mac id; migrate test off private workspaces Round 6 autoreview findings: [P1] remoteWorkspacesPreservingSnapshots stamps each foreground workspace with activeTicket?.macDeviceID (the synthetic manual-<host>:<port> id for an attach-ticket-less Mac), and setForegroundWorkspaceState only restamped nil ids — so round 5's real-id foreground KEY did not reach the rows. The same machine then looked like a different Mac (wrong counts/customizations; openWorkspace tried to switch to a nonexistent Mac). setForegroundWorkspaceState now stamps ALL foreground rows with the resolved foregroundMacDeviceID. [P1] The aggregation refactor made public private(set), but the iOS cmuxFeatureTests still assigned store.workspaces directly (7 sites), breaking the feature test target compile. Migrated them to the existing setWorkspacesForTesting DEBUG seam (reachable via @testable import), which writes the foreground per-Mac state so the derived list recomputes identically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drain in-flight restores before sign-out wipe (privacy race) Round 7 autoreview finding: [P1] removeAll() (sign-out wipe) cleared the local store BEFORE cancelling in-flight restores. A restore can pass its Task.isCancelled check, suspend inside inner.upsert, then the wipe runs and only afterwards cancels — but cancellation does not withdraw the already-queued upsert, so the previous account's Mac could be written back into the just-emptied store after sign-out (privacy boundary). removeAll now cancels AND DRAINS (awaits) the in-flight restores before wiping, so every pending write completes first and the wipe is final. Adds a deterministic regression test (GatedUpsertStore) that suspends a restore inside upsert across the wipe and asserts the store ends empty; it fails under the old wipe-then-cancel ordering. (The QuickLook finding the reviewer raised is out-of-scope: it comes from the origin/main merge, not this PR's diff, and the helper flagged it as ignored.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Team-safe backup mirror, non-loopback secondary dial, QR identity re-key Round 8 autoreview findings: [P1] mirrorAccountScope uploaded the WHOLE account's local rows into whichever team the backup client targets, so a multi-team user activating a host could copy other-team hosts into the selected team's per-team DO. Removed the whole-account mirror: upsert(markActive)/setActive now upload only the two records whose active flag actually changes (the newly-active host + the previously-active one, now cleared), preserving the backup's single-active invariant without dumping the account. (Local rows still carry no team id; a full team-scoped store is a separate v3-migration follow-up, but the leak vector is gone.) [P1] makeSecondaryClient proved a non-loopback route to fetch the attach ticket but then dialed supportedRoutes.first, which on a physical phone can be a higher-priority debugLoopback (127.0.0.1) — every secondary subscription dialed the phone itself, so the Mac was unreachable and dropped from aggregation. Now dials the proven route (exact host/port match, else any non-loopback, else first). [P2] A compact/anonymous QR pairing connects with an empty macDeviceID, so foreground state lands under the anonymous key with foregroundMacDeviceID nil. applyHostReportedIdentity adopted the real id into activeTicket but never updated the aggregate key, so the Computers screen showed the Mac as not-connected and aggregation (which excludes foregroundMacDeviceID) could open a DUPLICATE secondary to the same Mac. Added adoptForegroundMacIdentity to move/restamp the foreground per-Mac state and connection-pool entry to the reported id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stack teams: team-scoped paired-Mac data + lazy re-scope + nav drawer Implements full Stack-team support on iOS (the team-scope gap autoreview kept flagging is now closed by real per-team scoping rather than a documented caveat). A) Team-scoped local paired-Mac data - v3 SQLite migration adds a nullable team_id column (idempotent, mirrors v2); legacy/pre-v3 rows have NULL team and stay visible under EVERY team (loadAll filter is ) so an upgrade never hides existing hosts. - MobilePairedMac gains teamID; protocol upsert/loadAll/activeMac gain a teamID param with convenience overloads (teamID:nil) so existing call sites compile unchanged. markActive/setActive clear the active flag per (user, team) so activating in team A never deactivates team B. - BackingUpPairedMacStore injects the current team (teamIDProvider) into inner upsert/loadAll/activeMac; PairedMacRestore stamps restored rows with the team whose DO they came from. Multi-team users now only see/dial the active team's Macs. Tests: v2→v3 migration legacy visibility, per-team isolation, decorator injection. B) Lazy re-scope on team switch (keep the live terminal) - MobileShellComposite.currentTeamDidChange() re-subscribes presence, tears down secondary aggregation, invalidates the restore memo, and clears the pairedMacs/registryDevices caches — but never touches the foreground connection, so switching teams does NOT drop the live terminal. Rebuild is lazy (next foreground / Computers .task / pull). CMUXMobileRootView observes selectedTeamID (single mutation path). Test: foreground workspaces survive. C) Left-edge-swipe nav drawer - New MobileNavDrawerView (account header, Stack team list with current checked, Settings, Sign out) + EdgeSwipeDrawerContainer (leading-edge drag + scrim; toolbar button is the primary/accessible entry). Mounted in WorkspaceShellView over both layouts; WorkspaceListView gains a leading drawer button. Tapping a team only writes AuthCoordinator.selectedTeamID (the root re-scopes). en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix sign-out foreground reset, cumulative backup cap, iOS drawer type ref Autoreview round on the teams feature: [P1] signOut seeded the anonymous preview workspacesByMac entry but left foregroundMacDeviceID at the old real Mac id. The next connect() then captured that stale id as previousForegroundKey, so dropStalePreviousForeground (the round-6 stale-row fix) dropped the WRONG key and the preview rows survived alongside the newly-connected Mac. signOut now clears foregroundMacDeviceID and the foreground connection pool before seeding the anonymous entry, so foregroundMacKey matches the seeded key and the next connect drops the anonymous preview correctly. [P1] The new /v1/sync/paired-macs write path capped only LIVE records, so create→delete→repeat churn with fresh ids grew the DO unbounded across the tombstone GC window. Added MAX_PAIRED_MAC_RECORDS_PER_USER (5× live): a brand-new id is refused at the cumulative (live + retained-tombstone) cap; reviving a tombstoned id reuses its slot. Test churns to the cap and asserts new ids are refused while a revive is allowed. Also fixed the iOS archive compile error: MobileNavDrawerView named CMUXAuthTeam (from CMUXAuthCore, not a direct dep of CmuxMobileShellUI). Pass the team's id/displayName fields instead of the type, which also keeps the @Observable off the drawer's row closures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix iOS drawer compile: .rect arg order + gate drawer to iOS - EdgeSwipeDrawerContainer: UnevenRoundedRectangle .rect() wants bottomTrailingRadius before topTrailingRadius. - MobileNavDrawerView uses .listStyle(.insetGrouped) (iOS-only) and is only used on iOS, so gate the whole file behind #if os(iOS) (the package also compiles for macOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix WorkspaceListView call: openDrawer must match declaration order openDrawer is declared right after store, so pass it there in both call sites (Swift requires call arguments in declaration order). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drawer edge swipe: use native UIScreenEdgePanGestureRecognizer The SwiftUI DragGesture edge-strip fought the workspace list's scroll + row swipe actions (SwiftUI gestures don't coordinate with UIScrollView) and felt broken. Replace it with UIKit's UIScreenEdgePanGestureRecognizer — the same system recognizer behind the interactive back gesture — installed on the hosting view via a representable. It has screen-edge priority and coordinates with the scroll view automatically, and now drives the drawer INTERACTIVELY (the panel tracks the finger; commit on release by threshold/velocity). Gated to the compact root list only (isEdgeSwipeEnabled): a pushed detail uses the left edge for the system back swipe and the split layout has its own sidebar gesture, so the edge swipe would conflict there. The ☰ toolbar button opens the drawer in every state regardless (primary, accessible entry). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Replace team drawer with a native inline team picker in Settings Per dogfood feedback, the left-edge swipe drawer felt wrong (Apple also discourages hamburger drawers). Removed it entirely (EdgeSwipeDrawerContainer + MobileNavDrawerView deleted; WorkspaceShellView/WorkspaceListView reverted to the plain layout + the existing top-left Settings button) and put the team picker where it belongs: an INLINE Picker in the Settings sheet's account area — each Stack team is a row with a checkmark on the current one, one tap to switch. The team-scoped data + lazy re-scope (selectedTeamID observed by the root) are unchanged; only the entry point moved from a custom drawer to native Settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers screen: show each Mac's build channel (DEV+tag / Nightly / Stable) The Computers screen now labels which build each Mac runs, to debug 'which build is this host'. Full vertical: - Mac heartbeat (PresenceHeartbeatClient) now sends the app's bundleId alongside the existing CMUX_TAG. - Presence worker (validate/core/do.ts) parses, stores, and echoes bundleId on the instance (optional, bounded; a change re-syncs the device row). - iOS PresenceInstance decodes bundleId; PresenceMap.deviceSummary derives a build label via the new MacBuildChannel helper (a non-default tag => 'DEV · <tag>'; else the bundle-id suffix => Nightly/RC/Staging/Stable). - Computers UI: a small tinted pill next to each Mac's name (MacComputerRow) and a 'Build' row in the detail's Presence section (MacComputerDetailView). Tests: MacBuildChannel label derivation, worker bundleId carry, the Mac heartbeat body emits bundleId. en+ja localization for the new strings. Needs a dev-worker redeploy + mac & iOS rebuild for the value to flow on dogfood. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Build-channel label: component-based parse + handle future RC Align MacBuildChannel with the canonical SocketPathMarkerFiles.variant mapping: the channel is the component right AFTER com.cmuxterm.app (a tagged channel build appends a further .slug, e.g. com.cmuxterm.app.nightly.my-feature), so match the component, not a naive suffix. Adds 'rc' -> 'RC' so a future release-candidate desktop build (com.cmuxterm.app.rc) is labeled correctly the moment it ships, plus debug/dev -> DEV and an unknown future component -> no guess. Tests cover RC, slugged channel bundles, and the dev-tag-wins case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers: don't show contradictory 'presence unknown' when connected A Mac the phone is actively connected to (green, with workspaces) was still showing 'Presence: unknown' on its row — contradictory and confusing, since the live connection already proves the Mac is up. Presence is a SEPARATE signal (the Mac's heartbeat to the presence worker), and a dev phone watching the dev worker won't see a Mac that heartbeats to prod — so 'unknown' is common and meaningless next to 'Connected'. Row: when connected and the presence worker has no record, drop the 'Presence: unknown' and show just the route (real presence data still shows). Detail's 'Presence (from server)' section: when connected, say 'no heartbeat (connected directly)' instead of a bare 'unknown'. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers detail: a connected Mac reads 'Online' (connection is the truth) Follow-up to the presence-unknown fix: the root cause is that presence heartbeat is currently a DEV-only feature — stable cmux Macs don't announce presence (Release default OFF, no prod presence URL shipped), so a Mac you're connected to genuinely has no server heartbeat. Showing 'no heartbeat' for a Mac you're actively using reads as broken. Now, when the phone is connected, the detail's Presence section leads with 'Reports: Online' (the live connection proves it) plus a 'Source: this phone's connection (no server heartbeat)' clarifier, and the footer explains presence is a dev-only signal today. The row already shows just the route when connected. en+ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make presence production-ready: prod URL + follow the mobile toggle Presence was dev-only (Release default OFF, no prod URL). Make it ship on stable, gated on the mobile feature per the desired model: default OFF, ON when the user enables mobile. - PresenceSettings.isEnabled: an explicit override still wins, but with no stored value presence now FOLLOWS MobileHostService.isListeningEnabled (the iOS-pairing master switch). Default (mobile off) => off for privacy; turning on mobile pairing turns on presence automatically. Replaces the old DEBUG-on/Release-off. - Mac resolvedServiceURL: Release now defaults to the production worker (presence.cmux.dev) instead of nil, so a stable Mac with mobile on heartbeats to prod. Debug still uses the dev worker. - iOS PresenceServiceConfiguration: Release now defaults to the production worker too, so a stable iOS app subscribes to the same service stable Macs report to (env/UserDefaults/Info.plist overrides unchanged). On merge, CI (presence.yml) deploys the updated worker (bundleId + customization merge + tombstone cap) to prod, completing the production path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address iOS policy review cleanup * Fix paired Mac team scoping and aggregation guards * Satisfy paired Mac autoreview policy gate * Fix paired Mac team scope ownership * Fix quit confirmation reentrancy * Fix scoped backup and workspace action gates * Fix paired Mac legacy claim and selection remap * Fix team active legacy scope * Fix anonymous aggregation and backup actives * Fix visible legacy Mac customization scope * Fix legacy Mac active clearing scope * Make paired Mac backup decode tolerant * Fix stale route writes across team switches * Fix notification deeplink scope and backup URL joining * Provision secrets for isolated presence workers * Propagate paired Mac backup tombstones * Keep stale team loads from clearing current lists * Fix foreground suppression and secondary downgrades * Fix paired Mac backup review findings * Fix paired Mac scope and dismiss flush races * Satisfy iOS package convention lint * Fix visual line copy mode Ghostty API usage --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 | |
iOS: don't lose saved hosts/IPs on upgrade (paired-Mac backup + restore) (#6405) * ios: failing test — paired-Mac store strands data on future schema version Adds the paired-Mac backup/restore design doc and a red regression test: when an older build opens a paired-macs.sqlite3 whose user_version was bumped by a newer build, the store currently throws unknownSchemaVersion and every read fails, surfacing as total loss of the user's saved hosts even though the rows are still on disk. The fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: don't strand the paired-Mac store on a newer on-disk schema version runMigrations threw unknownSchemaVersion when user_version exceeded this build's, failing ensureReady and every read — so a user who upgraded (future schema vN) and then ran an older build saw all saved hosts as gone, though the rows were intact. Schema migrations are additive by contract, so older builds can still read the columns/tables they know. Degrade gracefully: log and read existing rows, never reset user_version (no destructive downgrade marker). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: per-user pairedMacs backup collection (server) Adds the first client-owned sync collection. The phone backs up its local saved-host list (including manually typed host/IPs, which live only on-device today) so it survives an app upgrade, bundle-id change, or reinstall. - New POST /v1/sync/paired-macs route → DO RPC backupPairedMacs(teamId, userId, ops), mirroring the trusted heartbeat RPC rather than expanding the live WS inbound surface. - Per-user privacy scoping by physical collection name pairedMacs:<userId> (userId is verified, never client input); outgoing frames are relabeled to the logical `pairedMacs` so the client never sees the suffix. Reuses the whole generic snapshot/delta/tombstone/GC machinery unchanged. - Subscribe forwards the verified x-presence-user-id; the DO pins it on the WS attachment and serves/broadcasts pairedMacs scoped to that user. - Per-user record cap, op bounds, route byte budget (mirrors heartbeat). - bun tests: parse bounds, per-user isolation, cap, relabel, tombstone, no-op idempotency. Full suite 144 pass; typecheck + wrangler dry-run clean. Additive and live-safe: new collection keys only, no class migration, old DO instances ignore the new RPC/collection during rollout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: GET /v1/sync/paired-macs restore path Adds the read side of the per-user backup: DO RPC listPairedMacs returns the live (non-tombstone) saved-host records newest-first, served by GET on the same authenticated, user-scoped route. The phone fetches this on sign-in to restore saved hosts after a reinstall or bundle-id change. Decouples restore from the WS sync client (which is built but not yet wired into the live app). bun test for list ordering + per-user isolation; strict test typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * presence: shape-aware equality for pairedMacs (no rev churn on timestamp drift) A backup upsert whose routes/name/active are unchanged but whose lastSeenAt advanced (every route refresh, and every full reconcile push on sign-in) must not re-mint a rev or broadcast a delta. Compare list-shape only, ignoring timestamps, mirroring the device-list collection. Stored lastSeenAt then tracks the last shape change (correct as-of-rev semantics for restore ordering). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: paired-Mac backup uploader + restore-on-sign-in Wires the iOS side of saved-host durability behind the mobilePairedMacBackup flag (DEBUG-on/Release-off, env/UserDefaults overridable): - PairedMacBackupClient: HTTP client for /v1/sync/paired-macs (POST ops, GET restore), auth mirrors PresenceClient/DeviceRegistryService. - BackingUpPairedMacStore: a MobilePairedMacStoring decorator so EVERY paired-Mac mutation flows through one seam — upsert/remove mirror to the DO best-effort (local stays authoritative); the sign-out wipe (removeAll) is NOT mirrored so the server backup survives for the next sign-in. - PairedMacRestore: on the first signed-in read, merge the backup into the local store — LWW by lastSeenAt (never clobber a newer local edit), insert missing hosts, and honor the backup's active host only when local has none (fresh install), so restore never hijacks the device's current active selection. - Composition root wraps the local store with the decorator when the flag is on and a presence URL resolves. Restore goes over HTTP (GET) rather than the WS sync client, which is built but not yet wired into the live app, so this feature is self-contained. No new user-facing strings (silent background backup/restore). swift test: 7 new tests pass (decorator mirroring, restore LWW/active rules, flag). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: make PairedMacRestore an injectable struct (package conventions) The iOS package-conventions lint forbids caseless enums with only static members (namespace-enum/namespace-type). Convert PairedMacRestore to a struct that takes the store + backup as injected dependencies with an instance run(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: address review — restore memoization, retry, team scope, setActive mirror Fixes from Cursor Bugbot / CodeRabbit / Greptile on the backup decorator: - removeAll (sign-out wipe) now resets the restore memo, so a same-launch re-sign-in restores again instead of returning an empty list (this was the exact sign-out→sign-in path; it was silently broken). - fetchAll returns nil on transport/auth failure (vs [] for genuinely empty), and restore is memoized only on a successful fetch — a transient first-launch failure now retries on the next read instead of stranding restore until restart. - Restore is scoped per (account, team), not per account: the backup DO is per-team, so switching teams re-restores (teamIDProvider injected). - Concurrent first reads share one in-flight restore Task, so a second read can't slip past the memo and observe a half-merged store. - setActive now mirrors the affected account scope to the DO (accurate records read back from the local store), so "select a host without connecting, then reinstall" no longer restores a stale active host. markActive upserts mirror the scope too, preserving the single-active invariant in the backup. - remove only mirrors a delete while signed in (no auth-failing noise for anonymous removals). - Migration test asserts user_version is left untouched (no downgrade marker). swift test: 11 backup + 5 migration tests pass; package-conventions lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): auto-publish this Mac's route to the user's pairedMacs backup DEV-only convenience so a fresh dev iOS build never needs a manual host entry. MacPairedMacBackupPublisher (DEBUG-on, env/UserDefaults overridable) registers the iOS-pairing-listener default on (so an attach route exists without toggling a setting), observes MobileHostService.statusUpdates(), and POSTs this Mac's deviceId+displayName+routes (active) to /v1/sync/paired-macs whenever routes change and the user is signed in. Routes are encoded via CmxAttachRoute so the iOS restore decodes them identically. Best-effort and Release-noop, mirroring PresenceHeartbeatClient. Bridges the dev gap where the registry (localhost) and presence devices projection don't deliver the Mac's route to the dev iOS build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac(dev): default iOS-pairing listener ON in DEBUG; drop runtime register The dev self-publisher needs the pairing listener bound so an attach route exists. Registering a UserDefaults fallback at runtime was clobbered by the settings runtime registering the catalog default, so move the default to the source: MobileCatalogSection.iOSPairingHost defaults true in DEBUG, false in Release (an explicit user toggle still wins). Release behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * mac: wire MacPairedMacBackupPublisher.swift into cmux.xcodeproj The new file was never added to the Xcode project, so it didn't compile, the AppDelegate reference was an undefined symbol, and every macOS build failed (reload-cloud kept the stale binary; CI would fail too). Add the four pbxproj entries (PBXBuildFile + PBXFileReference + Cloud group + app-target Sources phase), mirroring PresenceHeartbeatClient.swift. Verified: the dev Mac now auto-publishes its route to the user's pairedMacs backup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh AppDelegate Swift file-length budget for the publisher wiring The one-line MacPairedMacBackupPublisher.shared.configure(auth:) call (+ its comment) at the composition root grew AppDelegate.swift by 4 lines, tripping the file-length budget guard. Accept the minor known debt: the wiring belongs next to the other client configures. 17593 -> 17597. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: surface restored saved Macs on the disconnected screen Restoring saved Macs into the local store wasn't visible: the disconnected screen only auto-reconnected and otherwise jumped straight to "add device", so a restored Mac (e.g. on a fresh dev build, or when auto-reconnect can't reach it) never showed. Now the disconnected screen loads saved Macs (which also triggers the backup restore) and lists them for one-tap reconnect, only auto-presenting the pairing sheet when there are none to pick. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tapping a saved Mac dialed the phone's own loopback instead of Tailscale A restored/published Mac advertises both a debug_loopback route (127.0.0.1, priority 0) and a tailscale route. DEBUG builds keep .debugLoopback in supportedRouteKinds even on a physical device (for the on-device XCUITest mock host), so firstReconnectHostPortRoute, which picks the lowest-priority supported route, chose 127.0.0.1 — the phone's own loopback — and the connect silently failed without ever trying Tailscale. That made tapping a saved/restored Mac (switchToMac) and stored-Mac reconnect not connect on a device. Fix in route selection, not supportedKinds (XCUITests still need loopback): add preferNonLoopback (true on physical devices, false on the simulator where 127.0.0.1 IS the Mac). When set, a real route always wins over a .debugLoopback route regardless of priority; loopback is used only when it's the sole supported route. Tests cover device-prefers-tailscale, device-loopback-only fallback, and simulator-keeps-loopback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P1): tag workspaces with their Mac (macDeviceID) Foundation for the aggregated multi-Mac workspace list + machine filtering. Adds macDeviceID to MobileWorkspacePreview (additive, defaulted) and stamps it from the connected Mac's ticket where the workspace list is built. Invisible today (single Mac), but every workspace now records which Mac it's from, which P3 (aggregation) and P4 (group/filter by machine) build on. Design in plans/feat-ios-multi-mac-workspaces/DESIGN.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4a): compound workspace filter (read-state × machine) Replaces the single-dimension All/Unread filter enum with a composable struct: readState (all/unread) × machines (Set<macDeviceID>, empty = all), passing both only when a row satisfies both. Expresses "unread on Mac X and Mac Y" directly. The filter menu gains a machine multi-select section that appears once more than one machine is present (single-Mac users see the unchanged All/Unread control); the list views compile unchanged since .all/.matches/.isActive/.emptyStateText are preserved on the struct. en+ja localized. 6 model tests incl. the compound case. Machine names are wired in once aggregation (P3) provides multiple Macs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4b): machine-list derivation + prune for the filter Pure, tested helpers the filter UI and aggregation need: machineIDs(in:) gives the distinct machines present in a workspace list (first-appearance order, skips unknown-machine rows) to populate the filter's machine multi-select, and pruneMachines(notIn:) drops selections for machines that vanished so a stale machine filter never silently hides everything. Full model suite 52 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P2): per-Mac connection pool foundation Introduces MacConnection {macDeviceID, ticket, route, client, generation} and a connections:[macDeviceID:MacConnection] pool + foregroundMacDeviceID on the composite. The foreground attach now records its entry in the pool and teardown clears it. Additive and behavior-preserving (single-Mac == a pool of one); anonymous (empty-id) tickets are not pooled. This is the structure P3 builds on to open read-only connections to the user's other Macs and aggregate their workspaces. Compiles; route + backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3a): read-only secondary-Mac workspace fetch fetchSecondaryWorkspaceList(for:) opens a short-lived client to another paired Mac (reusing the manualHostTicket + workspace.list path, loopback-deprioritized on device) and returns its workspaces tagged with that Mac's macDeviceID, never touching the foreground connection. refreshSecondaryMacWorkspaces() populates secondaryWorkspacesByMac for every signed-in non-foreground Mac. Additive: not yet merged into the published list, so the single-Mac flow is untouched. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P3b): merge other Macs' workspaces into the list (flag-gated) Foreground connect now kicks a background refreshSecondaryMacWorkspaces(), and publishAggregatedWorkspaces() merges the other Macs' rows after the foreground Mac's (de-duped by id, per-Mac order preserved). Gated by multiMacAggregation (env/UserDefaults, DEBUG on / Release off) and a no-op when there are no secondaries, so the single-Mac list is byte-for-byte unchanged. Cleared on teardown. Compiles; route/backup tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P4): surface the machine multi-select in the filter WorkspaceListView derives the machines present in the (aggregated) workspace list and passes them to the filter menu, so the read-state × machine compound filter's machine section appears once more than one Mac has workspaces. Names come from the device tree; single-Mac shows the unchanged All/Unread control. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios(multi-mac P5): cross-Mac open switches the foreground connection openWorkspace now detects when the tapped workspace belongs to a Mac other than the current foreground connection (aggregated list) and switches the foreground to that Mac before selecting, so the terminal attaches to the right Mac. Gated by multiMacAggregation; no-op for single-Mac. Compiles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: refresh MobileShellComposite file-length budget for multi-Mac code The P2-P5 multi-Mac connection pool + aggregation + cross-Mac open added ~196 lines to MobileShellComposite.swift. The methods call private connect/ticket helpers so they can't move to a separate-file extension; accept the known debt. 5566 -> 5762. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh paired-Mac routes from backup before multi-Mac aggregation The aggregated multi-Mac workspace list only showed the foreground Mac's workspaces because secondary Macs' stored routes went stale: refreshSecondary- MacWorkspaces read the local paired-Mac store, but that store is only restored from the backup once per launch (memoized scope). When a secondary Mac relaunched on a new port and republished its route to the per-user backup, the iPhone never re-read it, so the read-only workspace fetch dialed a dead port and that Mac silently dropped out of the list. Fix: add PairedMacBackupRefreshing.refreshFromBackup(stackUserID:) on BackingUpPairedMacStore, which forces a backup re-fetch + LWW merge (bypassing the once-per-launch memo, coalescing with any in-flight restore). refreshSecondary- MacWorkspaces calls it before loadAll, so secondary routes are current before the fetch. LWW by lastSeenAt means the live foreground route is never clobbered. Principled: routes are kept fresh from the authoritative per-user backup at aggregation time, instead of relying on a single sign-in-time restore. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect first reachable Mac so home opens on the integrated list The home fell back to the "Your Macs" picker whenever there was no Mac marked active (or the active Mac's stored route was stale), forcing a manual tap before any workspaces showed. Rework the launch auto-connect so the home comes up connected to all Macs and shows one integrated list, without the picker: - Refresh saved-Mac routes from the per-user backup before dialing (LWW), so a Mac that relaunched on a new port is still reachable instead of failing to the picker. - Connect the explicitly-active Mac when reachable, otherwise the FIRST saved Mac with a usable route, instead of bailing when nothing is marked active. The other Macs are aggregated read-only (refreshSecondaryMacWorkspaces) into the same list, so the home is one integrated cross-Mac workspace list. The picker now only appears as the genuinely-offline fallback (no saved Mac has a usable route). Principled: auto-connect targets any reachable saved Mac with fresh routes, rather than depending on a single persisted "active" selection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: refresh routes from backup before manual Mac switch too switchToMac dialed the in-memory snapshot's routes, so manually switching to a Mac that had relaunched on a new port could fail on a stale route. Apply the same backup-refresh used by auto-connect and aggregation: refresh the per-user backup, re-read the target from the store, then dial its fresh route (falling back to the snapshot if the re-read yields nothing). Completes route-freshness across every saved-Mac connect path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: prefer IP-literal routes over MagicDNS hostnames for Mac connect/aggregation The multi-Mac aggregated list showed only the foreground Mac because the read-only secondary fetch to another Mac timed out. Root cause (confirmed on device via console diagnostics): a Mac can advertise three attach routes — debug_loopback, a MagicDNS hostname (e.g. <node>.<tailnet>.ts.net), and the raw tailscale IP. firstReconnectHostPortRoute picked the first non-loopback route, which was the MagicDNS hostname. MagicDNS doesn't resolve on every client (the phone here), so the attach-ticket request to the hostname timed out and that Mac was silently dropped from the aggregated list. A Mac that only advertises an IP route (no hostname) connected fine, which is why one Mac showed and the other didn't. Fix: among non-loopback routes, prefer one whose host is a numeric IP literal (IPv4/IPv6) over a hostname, since an IP is dialable without DNS. Falls back to a hostname route when no IP route exists, and loopback only as last resort. firstReconnectHostPortRoute is the shared selector for reconnect, manual switch, and secondary aggregation, so this fixes tap-to-connect to hostname-route Macs too. Added isIPLiteralHost + 3 route-selection tests incl. the exact repro. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: re-aggregate other Macs on pull-to-refresh and app foreground The aggregated multi-Mac list fetched each other Mac's workspaces once, on foreground attach. Workspaces created on a secondary Mac afterwards never appeared, because the read-only secondary list is a snapshot, not a live subscription (only the foreground Mac streams workspace.updated). Re-run refreshSecondaryMacWorkspaces from the two natural refresh points: - refreshWorkspaces() (pull-to-refresh) now re-aggregates after reloading the foreground list. - resumeForegroundRefresh() (app returns to foreground) re-aggregates when connected, so switching back to the app surfaces newly-created remote workspaces without a manual pull. Both gated on multiMacAggregationEnabled + an active foreground connection, so single-Mac behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: add transport-agnostic per-Mac workspace state + pure derivation Foundation for deriving the aggregated multi-Mac workspace list from a single source of truth instead of imperatively merging a live foreground list with stale secondary snapshots. MacWorkspaceState is the phone's view of ONE Mac's workspaces (workspaces + groups + liveness), keyed by macDeviceID, carrying NO transport/connection detail. MobileWorkspaceAggregation derives the flat ordered de-duplicated list (foreground first, then by display name) and the group sections as pure functions of [macID: MacWorkspaceState]. Same model + derivation whether each entry is fed by N direct phone->Mac connections (now) or one phone->Durable Object stream delivering per-Mac deltas (planned), so that migration is a transport swap, not a data-model change. 6 derivation tests. Not yet wired into the composite (next commit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: derive the workspace list from per-Mac state (slice 2) Wire the transport-agnostic data structure in: workspacesByMac is now the only stored workspace state, and `workspaces`/`workspaceGroups` are materialized derivations (private(set), assigned only by recomputeDerivedWorkspaceState). The foreground sync stream, secondary fetch, optimistic create-workspace/ terminal, preview, and reset all write per-Mac entries; the derived list recomputes via didSet. Anonymous/manual-ticket foreground uses a sentinel key. Deletes the two-sources-of-truth machinery: publishAggregatedWorkspaces (the re-merge band-aid) and secondaryWorkspacesByMac (the snapshot store). The foreground-update-overwrites-then-re-merges race is gone by construction: each Mac owns its entry, the aggregate is a pure function of them. clearRemoteConnection- Context keeps the offline foreground entry and drops only secondaries. Tests: 56 model+composite tests green (incl. new derivation + create/terminal/ preview paths). Test seam setWorkspacesForTesting replaces direct workspaces assignment. The 6 remaining failures are the pre-existing flaky render-grid timing tests, unchanged by this commit. Next (slice 3): per-Mac live workspace subscriptions feed workspacesByMac so remote-created workspaces appear with no refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: live per-Mac workspace subscriptions (slice 3) Each non-foreground Mac now holds a persistent read-only connection with its own live workspace.updated subscription that re-fetches its list on each change and writes its workspacesByMac entry, so a workspace created on another Mac appears with no pull-to-refresh. The derived list recomputes automatically. SecondaryMacSubscription holds the client + a fresh per-connection stream id + the consumer Task. refreshSecondaryMacWorkspaces is now an idempotent reconciler: establish a subscription for each newly-present secondary Mac, drop ones that disappeared or became the foreground. Fully best-effort and additive: any failure (no route, ticket/connect error, stream end) tears that entry down and the pull-to-refresh / foreground re-aggregate path remains the fallback, so a secondary subscription can never crash or block the foreground. Subscriptions are torn down on disconnect/sign-out (teardownSecondaryMacSubscriptions in clearRemoteConnectionContext). This is the N-persistent-connections model approved for now; the same per-Mac entries would later be fed by one phone->Durable Object stream (transport swap, no data-model change). 62 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: never show the Your Macs picker when Macs are saved The auto-connect made the home connect, but the root still fell back to the DisconnectedWorkspaceShellView picker whenever the foreground was not yet connected (initial connect window, or a failed/slow reconnect). Eliminate that: show the integrated workspace list whenever there are saved Macs, auto-connecting in the background, and only show the add-device flow when there are NO saved Macs at all. The list renders whatever has aggregated (foreground + live secondary subscriptions) and its toolbar carries settings/devices/sign-out, so nothing is lost by dropping the picker. Opening a workspace attaches its Mac on demand. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: auto-connect falls through to the next Mac when one is offline reconnectActiveMacIfAvailable picked a single target (active Mac, else first with a route) and connected once; if that Mac had a stored route but was actually down, the connect failed and the home showed "Mac offline" without trying any other reachable Mac. Build an ordered candidate list (active first, then every other Mac with a usable route) and try each via connectManualHost until one connects, so a single offline Mac never blocks the others. The restoring-gate deadline still caps the UI; the loop keeps trying in the background. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview findings (active-mac deactivation, stale secondary refresh, backup body cap) P1: PairedMacRestore deactivated the currently-active Mac when refreshFromBackup brought a fresher record for it (route refresh before reconnect/aggregation), losing the user's selection. Preserve the existing local active flag when updating an existing record; only honor the backup's active for records missing locally on a fresh install. Regression test added. P2: refreshSecondaryMacWorkspaces (foreground/pull) skipped Macs that already had a subscription, so a suspended/never-pushing secondary stream left a stale snapshot forever. Explicit refresh now reseeds existing secondary clients (and recreates dead ones), so a pull/foreground always updates the aggregate. P2: the paired-Mac backup POST reused the 16 KiB heartbeat cap while accepting up to 200 ops x 2 KiB routes, so legitimate backups 413'd and the best-effort client silently dropped them, staleing the server backup. readBoundedJson now takes a maxBytes; the backup route uses MAX_PAIRED_MAC_BACKUP_BYTES sized to the declared limits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 2 (sign-out restore race, stale machine filter blanks list) P1: BackingUpPairedMacStore.removeAll (sign-out wipe) cleared the inFlight map but did not cancel the restore tasks, so a backup fetch suspended across the wipe could resume and re-upsert the previous account's Macs into the emptied local store (privacy boundary). removeAll now cancels in-flight restores, and PairedMacRestore.run checks Task.isCancelled after its fetch and skips all writes. Regression test added. P2: the machine filter was never pruned, so when a filtered Mac left the aggregated list (a secondary disconnected, or fewer than two machines so the filter menu's machine section hid) the stale machine id rejected every row and stranded the user on a blank list with no visible control to clear it. This is the likely "blank black screen" after reconnect churn. WorkspaceListView now prunes filter.machines whenever the present machine set changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-machine avatar color + fix autoreview round 3 (wrong foreground key, secondary refetch storm, offline dead-end) Feature: workspaces from the same Mac now share one avatar color in the aggregated list (MachineAvatarPalette, keyed to macDeviceID with a workspace-id fallback and djb2 spread); the symbol still encodes terminal count. Unit-tested. P1: applyRemoteWorkspaceList wrote the foreground Mac's workspaces under the PREVIOUS foreground key because foregroundMacDeviceID was assigned after the apply. On a Mac A->B switch this stored B's list under A's key and the derived list went stale/empty once the id flipped. Set foregroundMacDeviceID before applying. P1: every secondary workspace.updated push awaited a full workspace.list with no coalescing, so a title/progress churn stream queued repeated full scans and MainActor aggregate updates. Added a per-Mac leading+trailing coalesced refresh (SecondaryMacSubscription.refreshTask/refreshPending) — bounded, no cancel/restart starvation. P2: an offline returning user whose auto-reconnect failed fell through to a workspace list whose only affordance (pull-to-refresh) no-ops while disconnected, with no reconnect control — a dead end. Added store.reconnectOrRefresh (reconnect when offline, refresh when connected), wired pull-to-refresh to it, and added a Reconnect button to the offline status row (localized en/ja). Keeps the integrated list as the only surface — no picker screen. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 4 (sign-out aggregation race, backup freshness on republish) P1: refreshSecondaryMacWorkspaces captured the account then awaited backup refresh, store load, client creation, and per-Mac fetches before mutating secondaryMacSubscriptions/workspacesByMac, while callers launched it in untracked Tasks. An in-flight pass could resume after sign-out/account switch and write the previous user's Macs/workspaces into the new UI. Added an isAggregationScopeValid guard (signed-in + same account + not cancelled) re-checked after every await before any mutation/connection, routed the pass through a tracked secondaryAggregationTask, and cancel it (plus tear down live secondary subscriptions) on sign-out and full reset. P1: a same-shape backup republish (Mac re-confirming its current live route) no-op'd without advancing the stored lastSeenAt, so the iOS LWW restore skipped the backup and kept dialing a stale local route. upsertRecord gained an opt-in freshnessOf; the paired-Mac path now refreshes lastSeenAt in place (same rev, no delta/broadcast) so restore sees the republish as fresh. Test extended. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 5 (unscoped aggregation read, restore-memo race, unbounded paired-Mac tombstones) P1: refreshSecondaryMacWorkspaces allowed a nil/empty account, so loadAll(stackUserID: nil) would read EVERY locally stored Mac across Stack accounts and could publish another account's workspaces into the UI. Now requires a concrete signed-in user before any load/connection (mirrors loadPairedMacs), keeping the post-await scope checks. P1: per-user paired-Mac delete tombstones were never garbage-collected — the alarm only GC'd the devices collection — so an authenticated client churning create/delete grew synced:/synctomb: storage without bound (the live-record cap resets on delete). Added listTombstonedCollections; the alarm now GCs every per-user pairedMacs:<userId> collection that holds tombstones and folds each next-GC deadline into its schedule. P2: a restore suspended at `await task.value` across a sign-out wipe could resume and re-insert restoredScopes (or clobber a post-wipe inFlight entry), making a same-launch re-sign-in skip the backup restore and show an empty list. Added a resetGeneration bumped by removeAll; both restore paths bail if it changed across the await. Tests: paired-Mac tombstone discovery+GC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 6 (restore cancellation per-await, switchToMac stale cache) P1: PairedMacRestore checked Task.isCancelled only once after fetchAll, so a sign-out wipe landing during loadAll or any later upsert let the loop reinsert the previous account's Macs into the wiped store. Now re-checks after the load and before every write, bailing with completed: false. P1: switchToMac hard-failed unless the target was in the in-memory pairedMacs cache, but the multi-Mac aggregation reads Macs straight from the store, so tapping a freshly-restored secondary Mac's workspace no-op'd and stranded the user on a workspace whose Mac never connected. switchToMac now resolves the target from the store (after the backup refresh), falling back to the cache. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: redesign devices screen as Computers management view (no connect step) Since workspaces from every Mac now appear together automatically, the device tree's "connect to a device" step is obsolete. Replace it with a Computers screen that manages the Macs signed in to the account: - One row per computer: machine-colored avatar (same color its workspaces use in the list, via the new shared MachineAvatarColors), name, online/last-seen status from durable-object presence, and workspace count. - Remove a computer via swipe or context menu (confirmed) -> forgetMac. - Add a computer via a toolbar + that opens the existing pairing flow (showAddDevice plumbed root -> shell -> list -> screen). - Drop the instance/tag/workspace expansion tree and Connect affordances; delete the now-dead DeviceTreeExpansionStore (+ tests) and the unused tree row snapshots, keeping only DeviceTreePresence. - New mobile.computers.* strings localized en + ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 7 (Release preview compile break, ineffective computer remove) P1: rootContent referenced WorkspaceListLayoutPreviewView directly, but that type is compiled only under `os(iOS) && DEBUG`, so a Release/iOS archive failed to type-check the branch ("cannot find ... in scope"). Added a DEBUG-wrapped workspaceListLayoutPreview helper (mirroring terminalLayoutPreview) so Release never names the gated type. P2: the Computers list was built from deviceTreeDevices (prefers the team registry), but Remove calls forgetMac, which only deletes the local paired-Mac backup row — so a registry-backed computer reappeared on the next registry load and Remove looked broken. Build the list from pairedMacs instead: this feature's source of truth, the same set that feeds the workspace aggregation and the exact rows forgetMac removes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: track CMUXMobileRootView in swift file-length budget (preview helper) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios/worker: fix autoreview round 8 (restore not cancelled on sign-out, idle-team tombstone GC, stale secondary rows) P1: signOut() never cancelled in-flight paired-Mac restores (it does not call removeAll), so the cancellation guards could not fire on the normal sign-out path; a restore suspended at its backup fetch could resume — possibly authorized with the next account's live token — and write rows for the previous account. Added PairedMacBackupRefreshing.cancelInFlightRestores (cancel tasks + bump reset generation, without wiping the per-user rows) and call it from signOut. P1: backupPairedMacs created delete tombstones but never scheduled an alarm, so an idle team (no presence instances/subscribers) would never wake to GC them and a create/delete churn grew DO storage unbounded. It now schedules the next tombstone-GC deadline for the user's collection after applying ops. P2: when a secondary Mac's event stream ended, the subscription was removed but its workspacesByMac entry stayed marked connected, leaving dead rows in the aggregate that taps routed into. The stream-end teardown now downgrades that Mac's state to unavailable so the rows show offline until a refresh re-establishes it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: fix autoreview round 9 (forget leaves secondary subscription, failed switch still opens workspace) P1: forgetMac removed the store row but, for a SECONDARY Mac, left its live read-only subscription and workspacesByMac entry intact, so the Computers screen's Remove left the forgotten Mac's workspaces in the list (still updating, tappable) until a later aggregation pass. forgetMac now cancels secondaryMacSubscriptions and clears workspacesByMac for that Mac. P2: openWorkspace awaited switchToMac for a cross-Mac workspace but selected the workspace even when the switch failed (no route / failed connect / fell back to the previous Mac), focusing a workspace whose Mac is not the live connection so terminal input targeted the wrong client. switchToMac now returns whether the foreground connection targets that Mac; openWorkspace bails (leaving the user on the list, with the Reconnect affordance) when it does not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: pop the compact stack when a cross-Mac workspace open fails (autoreview round 10 P1) The tap selects a workspace and pushes its detail synchronously, and openWorkspace runs from that detail's task — so an early return on a failed switchToMac left the user inside a workspace whose Mac never became the foreground connection (terminal input would route to the wrong live client). On switch failure, roll the selection back (selectedWorkspaceID = nil) so the compact stack pops to the list, where the offline row's Reconnect / next aggregation pass recovers the Mac. Known follow-ups (autoreview round 10, narrow edges not on the dogfood path): - sign-out-during-restore cancellation is fire-and-forget; the residual race needs the restore fetch bound to the captured account/team in the backup client. - an empty-macDeviceID QR connect keeps the foreground under the anonymous key and does not migrate workspacesByMac/foregroundMacDeviceID when the real id is adopted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: color computers by distinct position, not a colliding hash (fix two Macs both yellow) The avatar color hashed macDeviceID into 8 slots, so two Macs collided on one color ~1/8 of the time — Lawrence's two real device ids both hashed to slot 2 (yellow). Assign a DISTINCT color index per Mac by sorted device id in the aggregation (MobileWorkspaceAggregation.machineColorIndex), stamp it onto each derived workspace (machineColorIndex), and color the Computers rows from the same store map. Different Macs are now guaranteed distinct up to the palette size; the id hash remains only as a fallback outside the aggregated list. Tests added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: promote the live secondary connection on cross-Mac open instead of re-dialing (root-cause fix for "Mac offline") Architectural fix. Tapping a secondary Mac's workspace ran openWorkspace -> switchToMac -> connectManualHost -> connect(), which threw away the already-live, authorized read-only client in secondaryMacSubscriptions and re-dialed the foreground from scratch. That re-dial pipeline has several independent failure points — route re-derivation via refreshFromBackup LWW, the offline preflight, and connect()'s connectionGeneration supersession race — any of which strands the user as "Mac offline" even though a working client to that exact Mac exists. switchToMac now first tries promoteSecondaryToForeground: probe the live secondary client, and on success take ownership of it as the foreground connection (reuse the client/route/ticket, start terminal polling, re-aggregate the demoted Mac) with no re-dial. Falls back to the existing re-dial only when no live connection exists. This makes "offline on a reachable, already-aggregated Mac" unrepresentable. First cut of the larger unification (one MacConnection per Mac, foreground as a selector); the write-only `connections` pool and the duplicate connect path collapse in the follow-up. Orthogonal dev-only gap remains: a secondary on an ephemeral port the phone can't refresh (no dev registry) has no live connection to promote. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: Computers screen — drive the dot from the phone's real connection, show presence + route as diagnostics, refresh live while open The connection dot mixed two sources: the phone's live RPC status for the foreground Mac, but the Durable Object presence worker (the Mac's own heartbeat, not the phone's connection) for every other Mac. So a Mac the phone is actively connected to as a SECONDARY showed not-green because presence (unreliable on dev) didn't report it — exactly the "MacBook Pro not green" case. Now the dot is driven by the phone's own per-Mac connection (store.macConnectionStatuses, derived from each MacWorkspaceState.status: green=connected foreground/secondary, orange=reconnecting, grey=not connected), which updates reactively as subscriptions connect/drop. Presence and the dialable route (host:port) move to a separate diagnostic line, so a mismatch — "online via presence but the phone can't connect" — is a visible tailscale/route signal, and the user can see the exact endpoint. While the sheet is open it re-aggregates every 4s so a dropped Mac reconnects quickly. New strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: tap a computer for a comprehensive detail/debug sheet Tapping a row on the Computers screen now pushes MacComputerDetailView with the full per-Mac picture, separated so a connection problem is diagnosable: - Connection: the PHONE's live status to this Mac + workspace count + whether it is the active foreground. - Presence (from the Durable Object presence worker): online/offline + last seen, or "unknown", with a footer explaining that presence is the Mac's heartbeat, not the phone's connection, and that online-but-not-connected = a Tailscale/route problem. - Routes the phone can dial: every saved route (kind + host:port), selectable. - Identity: device id, paired-since, route-updated. - Actions: Reconnect, Remove. Rows are NavigationLinks into the sheet; strings localized en/ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: per-Mac custom name, color, and icon — synced across the user's devices Users can rename a computer and give it a custom color (8 swatches or any color) and icon (curated SF Symbols or any emoji) from the computer's detail sheet. The override wins over the Mac-reported name and the automatic color/icon everywhere: the workspace list avatars, the Computers screen, and the detail sheet. Persistence + sync reuse the existing per-user Durable Object paired-Mac backup: - MobilePairedMac + customName/customColor/customIcon; SQLite store v2 migration (additive nullable columns) + setCustomization (preserves the Mac's reported name/routes/active, bumps lastSeenAt for LWW). - PairedMacBackupRecord (Swift + worker) carries the fields; parse + bounds + pairedMacShapeEqual treat them as shape so a change mints a rev and broadcasts. - BackingUpPairedMacStore uploads the COMPLETE current record on every write (so a route refresh never clobbers a customization) and mirrors setCustomization. - PairedMacRestore applies the fields (LWW) so an edit on device A appears on B. - store.updateMacCustomization persists + uploads + re-derives; the aggregation stamps custom color/icon onto each workspace preview. Color is "palette:<n>" or "#RRGGBB"; icon is an SF Symbol name or an emoji (classified by non-ASCII). Strings localized en/ja. Tests: worker customization sync + shape; restore-applies + setCustomization-preserves. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ios: show a Reconnecting/Reconnect overlay on the terminal when disconnected (fix recurring "black screen") Recurring report: the phone drops its connection (dev route staleness with no registry to refresh) and the workspace detail keeps showing the now-dead terminal surface — an unrendered black screen with only a tiny status pill. The connection is fine to re-establish, but nothing tells the user that or offers an action. WorkspaceDetailView now overlays the terminal with TerminalDisconnectedOverlay whenever macConnectionStatus != .connected: a spinner for .reconnecting, and an offline icon + host + a Reconnect button (-> store.reconnectOrRefresh) for .unavailable. So a dropped connection reads as "Reconnecting…" with a clear action instead of a black void. Localized (reuses mobile.workspace.reconnect). Note: the underlying dev route-refresh gap (a secondary Mac on an ephemeral port the phone can't relearn without the registry) still requires a re-pair on dev; this makes that state visible + recoverable instead of silent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: add wrangler.dev.toml for safe cmux-presence-dev deploys Deploying the dev instance with `wrangler deploy --name cmux-presence-dev` inherits the production presence.cmux.dev custom domain from wrangler.toml (--name only overrides the worker name), STEALING the prod domain from cmux-presence and breaking prod auth (the dev worker uses the dev Stack project). Add a dedicated wrangler.dev.toml (workers_dev = true, no custom domain) so the dev instance stays on its *.workers.dev URL, and point the README at it with a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * workers/presence: encode a per-developer isolated-worker pattern for concurrent dev Problem: cmux-presence-dev is a SINGLE shared worker — last deploy wins, and an unmerged feature (the paired-Mac backup lives only on its branch) exists only on whoever deployed last, so two people working on the worker clobber each other. Pattern: each developer deploys their own cmux-presence-dev-<slug> via scripts/deploy-dev.sh. Each named worker has its OWN Durable Object namespace, so presence + paired-Mac-backup state is fully isolated per dev — any number of people dogfood worker changes at once without collision. Builds point at it via CMUX_PRESENCE_BASE_URL; the shared cmux-presence-dev stays the integration baseline (the script refuses reserved/prod names). To make a tapped iOS DEVICE build honor the override (it sees no shell env), the resolver now also reads an Info.plist key CMUXPresenceBaseURL — precedence env → UserDefaults → Info.plist → Debug default (tested). README documents the full pattern + guardrails; the remaining wiring (reload baking CMUXPresenceBaseURL into the tagged Info.plist next to CMUXDevTag) is flagged as a TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: track PresenceServiceConfiguration in swift file-length budget Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Harden paired-Mac v2 migration + bound Computers-screen polling Autoreview findings on the multi-Mac PR: [P1] v2 SQLite migration was neither idempotent nor atomic: it ran the three ADD COLUMN statements then bumped user_version separately, so a kill / disk-full / SQLite error after a partial apply stranded the DB at v1 with some v2 columns present. The next launch re-ran ADD COLUMN custom_name and failed with a duplicate-column error, bricking the paired-Mac store. Now each migration step runs inside one transaction (SQLite DDL + PRAGMA user_version are both transactional, so a partial apply rolls back and retries cleanly), and migrateToV2 only adds columns missing from PRAGMA table_info, which also recovers any dogfood device already left half-migrated by the earlier build. Adds a regression test that seeds a partially-applied v2 schema and asserts recovery. [P2] The Computers sheet polled store.reconnectOrRefresh() every 4s while open, which pulled the DO backup over the network and, when disconnected, re-dialed offline Macs on a fixed timer (battery/network fan-out). The online dots (presence) and secondary workspace lists are already push-driven, so the timer now calls a bounded refreshComputersScreen() (local row reload + coalesced foreground refresh only) on a gentler 10s cadence and leaves offline-Mac dialing to presence-push recovery and the explicit pull-to-refresh / per-Mac Reconnect button. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Route multi-Mac workspace mutations + reconnect to the owning Mac Round 2 autoreview findings on the multi-Mac aggregation: [P1] promoteSecondaryToForeground reused a live secondary connection as the new foreground but never started its terminal event stream: it called cancelRemoteOperationTasks() (which does NOT clear terminalEventListenerTask/ ID) and then startTerminalRefreshPolling(), which no-ops while a listener task is still installed. The promoted client got no terminal/workspace/ notification push events, so output stalled until another path restarted the stream. Now stop+start the listener (the existing == listenerID defer guard makes the old listener's async teardown safe). [P2] Aggregated workspace rows can belong to a secondary Mac, but rename/pin/ unread/close all sent to the single foreground remoteClient — wrong Mac, and with a colliding id could mutate a foreground workspace. sendWorkspaceMutation now resolves the workspace's owning Mac (workspaceMutationTarget) and routes to that Mac's client: foreground -> remoteClient + refreshWorkspaces(); a live secondary -> its client + scheduleSecondaryRefresh(); a known offline owner -> no send + snap back (never misroute to foreground). A failed secondary write no longer marks the foreground connection unavailable. [P2] The per-computer detail Reconnect button called reconnectOrRefresh() (foreground/active Mac) and ignored the computer being viewed. It now calls switchToMac(macDeviceID:), which promotes a live secondary to this Mac or re-dials it specifically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix foreground-Mac ownership: stale rows, switch fast path, test double Round 3 autoreview findings: [P1] Adding setCustomization to MobilePairedMacStoring broke the CmuxSyncStore test target: FakePairedStore conformed with only the old methods. Added a no-op setCustomization to the fake. [P1] On a foreground Mac change (connect A->B, promotion, or a real connect after an anonymous/sign-out session) the previous foreground/anonymous entry was left in workspacesByMac. recomputeDerivedWorkspaceState derives over every entry, so stale rows kept showing and could route actions/opens through stale ownership (regressing the old workspaces = remoteWorkspaces full replacement). Added dropStalePreviousForeground(): on the foreground flip it removes only the old foreground key (never a live/offline secondary, which aggregation re-adds), wired into both the connect path and promoteSecondaryToForeground. [P1] switchToMac's already-foreground fast path trusted the persisted isActive flag, which lags the live connection (promoteSecondaryToForeground writes it via an unawaited Task; stale during reconnect/switch races). It could return success without switching and leave input/mutations on the wrong Mac. Now gates on the live foregroundMacDeviceID == macDeviceID identity. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix offline-Mac dot + bake iOS presence override; doc team-scope limit Round 4 autoreview findings: [P2] clearRemoteConnectionContext set the global status unavailable but left the retained offline foreground entry in workspacesByMac with status .connected. macConnectionStatuses (the Computers screen's per-Mac dots) derives from those per-Mac states, so a just-disconnected Mac kept showing a green connected dot. Now downgrade the retained entry to .unavailable. [P2] The CMUXPresenceBaseURL Info.plist override was read by PresenceServiceConfiguration but never baked, so a tapped dev device build ignored a per-developer isolated worker. Wired the bake end to end: added the CMUX_PRESENCE_BASE_URL build setting to ios/Config/Shared.xcconfig (empty default) + the CMUXPresenceBaseURL key in ios/Config/Info.plist, and ios/scripts/reload.sh now passes $CMUX_PRESENCE_BASE_URL at both xcodebuild sites (next to CMUX_DEV_TAG). Release/TestFlight stay empty -> unaffected. Updated the worker README (no longer a TODO). [P2] Documented the per-(account, team) backup vs account-scoped local rows scope gap inline at mirrorAccountScope. Solo/single-team users are unaffected; proper multi-team isolation needs a team_id store column (v3 migration), tracked as a follow-up rather than expanding this upgrade-safety PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Bound Computers timer, key manual reconnects by real id, stop backup clobber Round 5 autoreview findings: [P1] refreshComputersScreen() (the open-sheet 10s timer) delegated to refreshWorkspaces(), which fans out refreshSecondaryMacWorkspaces() to every saved Mac and re-establishes/re-dials missing (offline) subscriptions — the reconnect storm the screen is meant to avoid (my earlier bounding fix was incomplete). It now does a foreground-only reload (riding any in-flight pull-to-refresh) and never initiates the secondary fan-out; recovery stays on presence-push + explicit pull/Reconnect. [P1] A Mac without mobile.attach_ticket.create connects via a synthetic manual-<host>:<port> ticket, and connect() keyed foreground state by ticket.macDeviceID. So a switch/reconnect to such a Mac stamped foreground workspaces with the synthetic id; filters, Computers rows, mutation routing, and aggregation no longer recognized the real Mac as foreground (and could open a duplicate secondary). connect()/connectManualHost now take the real pairedMacDeviceID hint (threaded from switchToMac, reconnect, device-row paths) and key foreground state + the connection pool under it. [P2] The Mac route-publisher omits customName/color/icon, but the worker treated absent fields as part of the record shape, so every Mac heartbeat minted a rev that wiped the user's iOS-set customizations and the next restore cleared them. Fix: iOS uploads now ALWAYS emit the three custom keys (null = reset-to-Auto, authoritative) via a custom encoder; the worker preserves stored customizations for any key an upload OMITS (the Mac), while a present key (iOS) still sets/clears it. Tests on both sides. (Dev worker needs redeploy for dogfood.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stamp foreground rows with real Mac id; migrate test off private workspaces Round 6 autoreview findings: [P1] remoteWorkspacesPreservingSnapshots stamps each foreground workspace with activeTicket?.macDeviceID (the synthetic manual-<host>:<port> id for an attach-ticket-less Mac), and setForegroundWorkspaceState only restamped nil ids — so round 5's real-id foreground KEY did not reach the rows. The same machine then looked like a different Mac (wrong counts/customizations; openWorkspace tried to switch to a nonexistent Mac). setForegroundWorkspaceState now stamps ALL foreground rows with the resolved foregroundMacDeviceID. [P1] The aggregation refactor made public private(set), but the iOS cmuxFeatureTests still assigned store.workspaces directly (7 sites), breaking the feature test target compile. Migrated them to the existing setWorkspacesForTesting DEBUG seam (reachable via @testable import), which writes the foreground per-Mac state so the derived list recomputes identically. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drain in-flight restores before sign-out wipe (privacy race) Round 7 autoreview finding: [P1] removeAll() (sign-out wipe) cleared the local store BEFORE cancelling in-flight restores. A restore can pass its Task.isCancelled check, suspend inside inner.upsert, then the wipe runs and only afterwards cancels — but cancellation does not withdraw the already-queued upsert, so the previous account's Mac could be written back into the just-emptied store after sign-out (privacy boundary). removeAll now cancels AND DRAINS (awaits) the in-flight restores before wiping, so every pending write completes first and the wipe is final. Adds a deterministic regression test (GatedUpsertStore) that suspends a restore inside upsert across the wipe and asserts the store ends empty; it fails under the old wipe-then-cancel ordering. (The QuickLook finding the reviewer raised is out-of-scope: it comes from the origin/main merge, not this PR's diff, and the helper flagged it as ignored.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Team-safe backup mirror, non-loopback secondary dial, QR identity re-key Round 8 autoreview findings: [P1] mirrorAccountScope uploaded the WHOLE account's local rows into whichever team the backup client targets, so a multi-team user activating a host could copy other-team hosts into the selected team's per-team DO. Removed the whole-account mirror: upsert(markActive)/setActive now upload only the two records whose active flag actually changes (the newly-active host + the previously-active one, now cleared), preserving the backup's single-active invariant without dumping the account. (Local rows still carry no team id; a full team-scoped store is a separate v3-migration follow-up, but the leak vector is gone.) [P1] makeSecondaryClient proved a non-loopback route to fetch the attach ticket but then dialed supportedRoutes.first, which on a physical phone can be a higher-priority debugLoopback (127.0.0.1) — every secondary subscription dialed the phone itself, so the Mac was unreachable and dropped from aggregation. Now dials the proven route (exact host/port match, else any non-loopback, else first). [P2] A compact/anonymous QR pairing connects with an empty macDeviceID, so foreground state lands under the anonymous key with foregroundMacDeviceID nil. applyHostReportedIdentity adopted the real id into activeTicket but never updated the aggregate key, so the Computers screen showed the Mac as not-connected and aggregation (which excludes foregroundMacDeviceID) could open a DUPLICATE secondary to the same Mac. Added adoptForegroundMacIdentity to move/restamp the foreground per-Mac state and connection-pool entry to the reported id. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Stack teams: team-scoped paired-Mac data + lazy re-scope + nav drawer Implements full Stack-team support on iOS (the team-scope gap autoreview kept flagging is now closed by real per-team scoping rather than a documented caveat). A) Team-scoped local paired-Mac data - v3 SQLite migration adds a nullable team_id column (idempotent, mirrors v2); legacy/pre-v3 rows have NULL team and stay visible under EVERY team (loadAll filter is ) so an upgrade never hides existing hosts. - MobilePairedMac gains teamID; protocol upsert/loadAll/activeMac gain a teamID param with convenience overloads (teamID:nil) so existing call sites compile unchanged. markActive/setActive clear the active flag per (user, team) so activating in team A never deactivates team B. - BackingUpPairedMacStore injects the current team (teamIDProvider) into inner upsert/loadAll/activeMac; PairedMacRestore stamps restored rows with the team whose DO they came from. Multi-team users now only see/dial the active team's Macs. Tests: v2→v3 migration legacy visibility, per-team isolation, decorator injection. B) Lazy re-scope on team switch (keep the live terminal) - MobileShellComposite.currentTeamDidChange() re-subscribes presence, tears down secondary aggregation, invalidates the restore memo, and clears the pairedMacs/registryDevices caches — but never touches the foreground connection, so switching teams does NOT drop the live terminal. Rebuild is lazy (next foreground / Computers .task / pull). CMUXMobileRootView observes selectedTeamID (single mutation path). Test: foreground workspaces survive. C) Left-edge-swipe nav drawer - New MobileNavDrawerView (account header, Stack team list with current checked, Settings, Sign out) + EdgeSwipeDrawerContainer (leading-edge drag + scrim; toolbar button is the primary/accessible entry). Mounted in WorkspaceShellView over both layouts; WorkspaceListView gains a leading drawer button. Tapping a team only writes AuthCoordinator.selectedTeamID (the root re-scopes). en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix sign-out foreground reset, cumulative backup cap, iOS drawer type ref Autoreview round on the teams feature: [P1] signOut seeded the anonymous preview workspacesByMac entry but left foregroundMacDeviceID at the old real Mac id. The next connect() then captured that stale id as previousForegroundKey, so dropStalePreviousForeground (the round-6 stale-row fix) dropped the WRONG key and the preview rows survived alongside the newly-connected Mac. signOut now clears foregroundMacDeviceID and the foreground connection pool before seeding the anonymous entry, so foregroundMacKey matches the seeded key and the next connect drops the anonymous preview correctly. [P1] The new /v1/sync/paired-macs write path capped only LIVE records, so create→delete→repeat churn with fresh ids grew the DO unbounded across the tombstone GC window. Added MAX_PAIRED_MAC_RECORDS_PER_USER (5× live): a brand-new id is refused at the cumulative (live + retained-tombstone) cap; reviving a tombstoned id reuses its slot. Test churns to the cap and asserts new ids are refused while a revive is allowed. Also fixed the iOS archive compile error: MobileNavDrawerView named CMUXAuthTeam (from CMUXAuthCore, not a direct dep of CmuxMobileShellUI). Pass the team's id/displayName fields instead of the type, which also keeps the @Observable off the drawer's row closures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix iOS drawer compile: .rect arg order + gate drawer to iOS - EdgeSwipeDrawerContainer: UnevenRoundedRectangle .rect() wants bottomTrailingRadius before topTrailingRadius. - MobileNavDrawerView uses .listStyle(.insetGrouped) (iOS-only) and is only used on iOS, so gate the whole file behind #if os(iOS) (the package also compiles for macOS). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix WorkspaceListView call: openDrawer must match declaration order openDrawer is declared right after store, so pass it there in both call sites (Swift requires call arguments in declaration order). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Drawer edge swipe: use native UIScreenEdgePanGestureRecognizer The SwiftUI DragGesture edge-strip fought the workspace list's scroll + row swipe actions (SwiftUI gestures don't coordinate with UIScrollView) and felt broken. Replace it with UIKit's UIScreenEdgePanGestureRecognizer — the same system recognizer behind the interactive back gesture — installed on the hosting view via a representable. It has screen-edge priority and coordinates with the scroll view automatically, and now drives the drawer INTERACTIVELY (the panel tracks the finger; commit on release by threshold/velocity). Gated to the compact root list only (isEdgeSwipeEnabled): a pushed detail uses the left edge for the system back swipe and the split layout has its own sidebar gesture, so the edge swipe would conflict there. The ☰ toolbar button opens the drawer in every state regardless (primary, accessible entry). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Replace team drawer with a native inline team picker in Settings Per dogfood feedback, the left-edge swipe drawer felt wrong (Apple also discourages hamburger drawers). Removed it entirely (EdgeSwipeDrawerContainer + MobileNavDrawerView deleted; WorkspaceShellView/WorkspaceListView reverted to the plain layout + the existing top-left Settings button) and put the team picker where it belongs: an INLINE Picker in the Settings sheet's account area — each Stack team is a row with a checkmark on the current one, one tap to switch. The team-scoped data + lazy re-scope (selectedTeamID observed by the root) are unchanged; only the entry point moved from a custom drawer to native Settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers screen: show each Mac's build channel (DEV+tag / Nightly / Stable) The Computers screen now labels which build each Mac runs, to debug 'which build is this host'. Full vertical: - Mac heartbeat (PresenceHeartbeatClient) now sends the app's bundleId alongside the existing CMUX_TAG. - Presence worker (validate/core/do.ts) parses, stores, and echoes bundleId on the instance (optional, bounded; a change re-syncs the device row). - iOS PresenceInstance decodes bundleId; PresenceMap.deviceSummary derives a build label via the new MacBuildChannel helper (a non-default tag => 'DEV · <tag>'; else the bundle-id suffix => Nightly/RC/Staging/Stable). - Computers UI: a small tinted pill next to each Mac's name (MacComputerRow) and a 'Build' row in the detail's Presence section (MacComputerDetailView). Tests: MacBuildChannel label derivation, worker bundleId carry, the Mac heartbeat body emits bundleId. en+ja localization for the new strings. Needs a dev-worker redeploy + mac & iOS rebuild for the value to flow on dogfood. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Build-channel label: component-based parse + handle future RC Align MacBuildChannel with the canonical SocketPathMarkerFiles.variant mapping: the channel is the component right AFTER com.cmuxterm.app (a tagged channel build appends a further .slug, e.g. com.cmuxterm.app.nightly.my-feature), so match the component, not a naive suffix. Adds 'rc' -> 'RC' so a future release-candidate desktop build (com.cmuxterm.app.rc) is labeled correctly the moment it ships, plus debug/dev -> DEV and an unknown future component -> no guess. Tests cover RC, slugged channel bundles, and the dev-tag-wins case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers: don't show contradictory 'presence unknown' when connected A Mac the phone is actively connected to (green, with workspaces) was still showing 'Presence: unknown' on its row — contradictory and confusing, since the live connection already proves the Mac is up. Presence is a SEPARATE signal (the Mac's heartbeat to the presence worker), and a dev phone watching the dev worker won't see a Mac that heartbeats to prod — so 'unknown' is common and meaningless next to 'Connected'. Row: when connected and the presence worker has no record, drop the 'Presence: unknown' and show just the route (real presence data still shows). Detail's 'Presence (from server)' section: when connected, say 'no heartbeat (connected directly)' instead of a bare 'unknown'. en+ja localized. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Computers detail: a connected Mac reads 'Online' (connection is the truth) Follow-up to the presence-unknown fix: the root cause is that presence heartbeat is currently a DEV-only feature — stable cmux Macs don't announce presence (Release default OFF, no prod presence URL shipped), so a Mac you're connected to genuinely has no server heartbeat. Showing 'no heartbeat' for a Mac you're actively using reads as broken. Now, when the phone is connected, the detail's Presence section leads with 'Reports: Online' (the live connection proves it) plus a 'Source: this phone's connection (no server heartbeat)' clarifier, and the footer explains presence is a dev-only signal today. The row already shows just the route when connected. en+ja. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Make presence production-ready: prod URL + follow the mobile toggle Presence was dev-only (Release default OFF, no prod URL). Make it ship on stable, gated on the mobile feature per the desired model: default OFF, ON when the user enables mobile. - PresenceSettings.isEnabled: an explicit override still wins, but with no stored value presence now FOLLOWS MobileHostService.isListeningEnabled (the iOS-pairing master switch). Default (mobile off) => off for privacy; turning on mobile pairing turns on presence automatically. Replaces the old DEBUG-on/Release-off. - Mac resolvedServiceURL: Release now defaults to the production worker (presence.cmux.dev) instead of nil, so a stable Mac with mobile on heartbeats to prod. Debug still uses the dev worker. - iOS PresenceServiceConfiguration: Release now defaults to the production worker too, so a stable iOS app subscribes to the same service stable Macs report to (env/UserDefaults/Info.plist overrides unchanged). On merge, CI (presence.yml) deploys the updated worker (bundleId + customization merge + tombstone cap) to prod, completing the production path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Address iOS policy review cleanup * Fix paired Mac team scoping and aggregation guards * Satisfy paired Mac autoreview policy gate * Fix paired Mac team scope ownership * Fix quit confirmation reentrancy * Fix scoped backup and workspace action gates * Fix paired Mac legacy claim and selection remap * Fix team active legacy scope * Fix anonymous aggregation and backup actives * Fix visible legacy Mac customization scope * Fix legacy Mac active clearing scope * Make paired Mac backup decode tolerant * Fix stale route writes across team switches * Fix notification deeplink scope and backup URL joining * Provision secrets for isolated presence workers * Propagate paired Mac backup tombstones * Keep stale team loads from clearing current lists * Fix foreground suppression and secondary downgrades * Fix paired Mac backup review findings * Fix paired Mac scope and dismiss flush races * Satisfy iOS package convention lint * Fix visual line copy mode Ghostty API usage --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> | 3 个月前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 3 个月前 | ||
| 3 个月前 | ||
| 3 个月前 | ||
| 3 个月前 | ||
| 3 个月前 | ||
| 3 个月前 |