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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * ios: track CMUXMobileRootView in swift file-length budget (preview helper) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * ci: track PresenceServiceConfiguration in swift file-length budget Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]> * 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) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
9332c5fdd3
commit
58c2e40065
@@ -2,11 +2,11 @@
|
||||
# Format: max_lines<TAB>relative path
|
||||
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
|
||||
34546 CLI/cmux.swift
|
||||
17837 Sources/AppDelegate.swift
|
||||
17841 Sources/AppDelegate.swift
|
||||
16132 Sources/ContentView.swift
|
||||
13952 Sources/TerminalController.swift
|
||||
13824 Sources/TerminalController.swift
|
||||
12828 Sources/Workspace.swift
|
||||
12240 Sources/GhosttyTerminalView.swift
|
||||
12237 Sources/GhosttyTerminalView.swift
|
||||
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
|
||||
11929 Sources/Panels/BrowserPanel.swift
|
||||
9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift
|
||||
@@ -14,13 +14,13 @@
|
||||
7986 Sources/Panels/BrowserPanelView.swift
|
||||
7366 cmuxTests/WorkspaceUnitTests.swift
|
||||
7218 cmuxTests/WorkspaceRemoteConnectionTests.swift
|
||||
6831 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
|
||||
6317 cmuxTests/SessionPersistenceTests.swift
|
||||
6217 cmuxTests/GhosttyConfigTests.swift
|
||||
6183 Sources/TabManager.swift
|
||||
6084 Sources/TextBoxInput.swift
|
||||
5915 cmuxTests/TerminalAndGhosttyTests.swift
|
||||
5573 cmuxTests/BrowserConfigTests.swift
|
||||
5534 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
|
||||
4487 Sources/Panels/FilePreviewPanel.swift
|
||||
4478 Sources/cmuxApp.swift
|
||||
4401 cmuxTests/BrowserPanelTests.swift
|
||||
@@ -88,22 +88,23 @@
|
||||
1021 cmuxUITests/TerminalCmdClickUITests.swift
|
||||
1009 cmuxTests/CmuxTopSnapshotScopeTests.swift
|
||||
1006 cmuxTests/CmuxSSHURLRequestTests.swift
|
||||
1002 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift
|
||||
951 Sources/App/TerminalDirectoryOpenSupport.swift
|
||||
947 Sources/TerminalNotificationPolicy.swift
|
||||
945 Sources/SessionIndexRegisteredAgents.swift
|
||||
937 Sources/TextBoxMentionIndexStore.swift
|
||||
934 Sources/App/ShortcutRoutingSupport.swift
|
||||
935 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
|
||||
928 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
|
||||
926 Sources/DockPanelView.swift
|
||||
920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift
|
||||
918 cmuxTests/WorkspaceGroupTests.swift
|
||||
905 Sources/CmuxSSHURLRequest.swift
|
||||
899 Sources/Panels/MarkdownWebRenderer.swift
|
||||
885 Sources/Panels/TerminalPanel.swift
|
||||
881 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
|
||||
877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
|
||||
871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift
|
||||
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
|
||||
865 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
|
||||
859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
|
||||
847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift
|
||||
845 cmuxTests/SSHStartupSignalLifecycleTests.swift
|
||||
@@ -111,6 +112,7 @@
|
||||
825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
|
||||
824 Sources/MainWindowFocusController.swift
|
||||
810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift
|
||||
803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift
|
||||
802 Sources/WorkspaceContentView.swift
|
||||
797 Sources/ClosedItemHistory.swift
|
||||
779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift
|
||||
@@ -190,6 +192,7 @@
|
||||
558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift
|
||||
552 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift
|
||||
549 Sources/Panels/BrowserAutomation.swift
|
||||
547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift
|
||||
541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift
|
||||
540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift
|
||||
539 CLI/CMUXCLI+Themes.swift
|
||||
@@ -211,6 +214,7 @@
|
||||
520 cmuxTests/MainWindowVisibilityControllerTests.swift
|
||||
519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift
|
||||
519 Sources/CmuxConfigExecutor.swift
|
||||
518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
|
||||
518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift
|
||||
516 Sources/TerminalImageTransfer.swift
|
||||
514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
|
||||
@@ -219,11 +223,10 @@
|
||||
507 Sources/TerminalControllerTopSupport.swift
|
||||
506 Sources/App/MainWindowVisibilityController.swift
|
||||
505 cmuxUITests/DisplayResolutionRegressionUITests.swift
|
||||
504 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
|
||||
504 cmuxTests/TerminalNotificationSocketActionTests.swift
|
||||
503 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
|
||||
503 Sources/Settings/ConfigSource.swift
|
||||
502 Sources/CmuxEventPublishing.swift
|
||||
502 Sources/KeyboardShortcutContext.swift
|
||||
502 Sources/RemoteTmuxSessionMirror.swift
|
||||
501 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
|
||||
500 Sources/KeyboardShortcutRecorder.swift
|
||||
|
||||
|
@@ -393,14 +393,19 @@ let sortKey: @Sendable (SyncWireRecord) -> Double = { DeviceSyncFacade.sortKey(f
|
||||
actor FakePairedStore: MobilePairedMacStoring {
|
||||
var macs: [MobilePairedMac]
|
||||
init(macs: [MobilePairedMac]) { self.macs = macs }
|
||||
func upsert(macDeviceID: String, displayName: String?, routes: [CmxAttachRoute], markActive: Bool, stackUserID: String?, now: Date) async throws {}
|
||||
func loadAll(stackUserID: String?) async throws -> [MobilePairedMac] {
|
||||
func upsert(macDeviceID: String, displayName: String?, routes: [CmxAttachRoute], markActive: Bool, stackUserID: String?, teamID: String?, now: Date) async throws {}
|
||||
func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac] {
|
||||
guard let stackUserID else { return macs }
|
||||
return macs.filter { $0.stackUserID == stackUserID }
|
||||
}
|
||||
func activeMac(stackUserID: String?) async throws -> MobilePairedMac? { nil }
|
||||
func setActive(macDeviceID: String) async throws {}
|
||||
func remove(macDeviceID: String) async throws {}
|
||||
func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? { nil }
|
||||
func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {}
|
||||
func clearActive(stackUserID: String?, teamID: String?) async throws {}
|
||||
func setCustomization(
|
||||
macDeviceID: String, customName: String?, customColor: String?,
|
||||
customIcon: String?, stackUserID: String?, teamID: String?, now: Date
|
||||
) async throws {}
|
||||
func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {}
|
||||
func removeAll() async throws {}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,10 +20,34 @@ public struct MobilePairedMac: Codable, Equatable, Sendable, Identifiable {
|
||||
public var isActive: Bool
|
||||
/// Stack Auth user that owns this pairing, if any.
|
||||
public var stackUserID: String?
|
||||
/// Stack team this pairing belongs to (the team whose per-team backup it was
|
||||
/// paired/restored under). `nil` for a pre-v3 row or an anonymous pairing; a
|
||||
/// nil-team row is visible under every team until re-stamped. Scopes the local
|
||||
/// list so a multi-team user only sees the current team's Macs.
|
||||
public var teamID: String?
|
||||
/// User's custom name override. When set, wins over the Mac-reported
|
||||
/// ``displayName`` everywhere. `nil` = use the Mac-reported name. Synced per
|
||||
/// user so the rename appears on every signed-in device.
|
||||
public var customName: String?
|
||||
/// User's custom color override, synced per user. `nil` = the automatic
|
||||
/// position-based color. `"palette:<n>"` selects one of the built-in machine
|
||||
/// colors; `"#RRGGBB"` is a custom color. Opaque to the store/worker.
|
||||
public var customColor: String?
|
||||
/// User's custom icon override, synced per user. `nil` = the automatic icon.
|
||||
/// An SF Symbol name (ASCII, e.g. `"desktopcomputer"`) or an emoji.
|
||||
public var customIcon: String?
|
||||
|
||||
/// The Mac device identifier doubles as the stable `Identifiable` id.
|
||||
public var id: String { macDeviceID }
|
||||
|
||||
/// The name to show: the user's custom override if set, else the Mac-reported
|
||||
/// name, else the device id.
|
||||
public var resolvedName: String {
|
||||
if let customName, !customName.isEmpty { return customName }
|
||||
if let displayName, !displayName.isEmpty { return displayName }
|
||||
return macDeviceID
|
||||
}
|
||||
|
||||
/// Creates a paired-Mac value.
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Stable identifier of the paired Mac device.
|
||||
@@ -40,7 +64,11 @@ public struct MobilePairedMac: Codable, Equatable, Sendable, Identifiable {
|
||||
createdAt: Date,
|
||||
lastSeenAt: Date,
|
||||
isActive: Bool,
|
||||
stackUserID: String?
|
||||
stackUserID: String?,
|
||||
teamID: String? = nil,
|
||||
customName: String? = nil,
|
||||
customColor: String? = nil,
|
||||
customIcon: String? = nil
|
||||
) {
|
||||
self.macDeviceID = macDeviceID
|
||||
self.displayName = displayName
|
||||
@@ -49,5 +77,9 @@ public struct MobilePairedMac: Codable, Equatable, Sendable, Identifiable {
|
||||
self.lastSeenAt = lastSeenAt
|
||||
self.isActive = isActive
|
||||
self.stackUserID = stackUserID
|
||||
self.teamID = teamID
|
||||
self.customName = customName
|
||||
self.customColor = customColor
|
||||
self.customIcon = customIcon
|
||||
}
|
||||
}
|
||||
|
||||
+380
-62
@@ -14,7 +14,7 @@ private let pairedMacStoreLog = Logger(subsystem: "com.cmuxterm.app", category:
|
||||
/// inject it as `any MobilePairedMacStoring`.
|
||||
public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
/// The schema version this build creates and migrates to.
|
||||
public static let currentSchemaVersion: Int32 = 1
|
||||
public static let currentSchemaVersion: Int32 = 4
|
||||
|
||||
private let dbPath: String
|
||||
// `nonisolated(unsafe)` only so the (Swift 6 nonisolated) `deinit` can close
|
||||
@@ -96,16 +96,57 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
|
||||
private func runMigrations() throws {
|
||||
let version = try userVersion()
|
||||
// Each case applies its schema changes AND bumps `user_version` inside one
|
||||
// transaction, so a kill / disk-full / SQLite error mid-migration rolls the
|
||||
// whole step back (SQLite DDL and `PRAGMA user_version` are both
|
||||
// transactional). The store then reopens at the prior version and retries
|
||||
// the step cleanly instead of being stranded with a partially-applied
|
||||
// schema whose `user_version` never advanced.
|
||||
switch version {
|
||||
case 0:
|
||||
try migrateToV1()
|
||||
try setUserVersion(1)
|
||||
fallthrough
|
||||
try transaction {
|
||||
try migrateToV1()
|
||||
try migrateToV2()
|
||||
try migrateToV3()
|
||||
try migrateToV4()
|
||||
try setUserVersion(4)
|
||||
}
|
||||
case 1:
|
||||
try transaction {
|
||||
try migrateToV2()
|
||||
try migrateToV3()
|
||||
try migrateToV4()
|
||||
try setUserVersion(4)
|
||||
}
|
||||
case 2:
|
||||
try transaction {
|
||||
try migrateToV3()
|
||||
try migrateToV4()
|
||||
try setUserVersion(4)
|
||||
}
|
||||
case 3:
|
||||
try transaction {
|
||||
try migrateToV4()
|
||||
try setUserVersion(4)
|
||||
}
|
||||
case 4:
|
||||
break
|
||||
default:
|
||||
// Future schema; fail closed so we don't corrupt on downgrade.
|
||||
throw MobilePairedMacStoreError.unknownSchemaVersion(Int(version))
|
||||
// A newer build wrote a higher schema version. Schema migrations are
|
||||
// additive by contract — older builds keep reading the columns and
|
||||
// tables they already know (see
|
||||
// plans/feat-ios-paired-mac-backup/DESIGN.md §4 and the same
|
||||
// discipline in docs/presence-service.md). Throwing here would make
|
||||
// `ensureReady` fail and every read surface as a TOTAL loss of the
|
||||
// user's paired Macs across an upgrade-then-older-build open, even
|
||||
// though the v1 rows are intact on disk. Degrade gracefully instead:
|
||||
// leave `user_version` untouched (never write a destructive downgrade
|
||||
// marker) and read what this build understands. The DO backup is the
|
||||
// safety net if a future non-additive change ever makes the local
|
||||
// read genuinely fail.
|
||||
pairedMacStoreLog.warning(
|
||||
"paired-mac store schema v\(version) is newer than this build (v\(Self.currentSchemaVersion)); reading known columns only"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,45 +176,196 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
try exec("CREATE INDEX IF NOT EXISTS idx_routes_device ON mac_routes(mac_device_id);")
|
||||
}
|
||||
|
||||
/// v2: user-editable, per-user-synced customizations (additive columns, all
|
||||
/// nullable so older rows and older builds are unaffected).
|
||||
///
|
||||
/// Idempotent: only adds columns that are missing. The transactional
|
||||
/// `runMigrations` step already makes this restart-safe for new devices, but
|
||||
/// the column check also recovers any device that ran an earlier,
|
||||
/// non-transactional build of this migration and was left partially applied
|
||||
/// (some columns added, `user_version` still 1) — re-running here just adds
|
||||
/// the remaining columns instead of failing on a duplicate-column error.
|
||||
private func migrateToV2() throws {
|
||||
let existing = try tableColumns("paired_macs")
|
||||
for column in ["custom_name", "custom_color", "custom_icon"]
|
||||
where !existing.contains(column) {
|
||||
try exec("ALTER TABLE paired_macs ADD COLUMN \(column) TEXT;")
|
||||
}
|
||||
}
|
||||
|
||||
/// v3: per-Stack-team scoping. The backup Durable Object is per-(account, team),
|
||||
/// so a row needs the team it belongs to. Additive + nullable: pre-v3 rows have
|
||||
/// `team_id = NULL` and stay visible under every team (a non-nil team filter is
|
||||
/// `team_id IS ? OR team_id IS NULL`) so an upgrade never hides existing hosts;
|
||||
/// they get stamped with the active team on the next upsert/route refresh.
|
||||
/// Idempotent, like ``migrateToV2``.
|
||||
private func migrateToV3() throws {
|
||||
let existing = try tableColumns("paired_macs")
|
||||
if !existing.contains("team_id") {
|
||||
try exec("ALTER TABLE paired_macs ADD COLUMN team_id TEXT;")
|
||||
}
|
||||
try exec("CREATE INDEX IF NOT EXISTS idx_macs_team ON paired_macs(stack_user_id, team_id);")
|
||||
}
|
||||
|
||||
/// v4: make `(mac_device_id, stack_user_id, team_id)` the durable identity by
|
||||
/// adding a non-null normalized `owner_key` and carrying it into `mac_routes`.
|
||||
///
|
||||
/// SQLite UNIQUE/PRIMARY KEY constraints treat NULL values as distinct, so a
|
||||
/// literal nullable composite key would still allow duplicate anonymous or
|
||||
/// team-less rows. `owner_key` is the normalized scope discriminator used only
|
||||
/// for constraints and foreign keys; the readable columns remain
|
||||
/// `stack_user_id` and `team_id`.
|
||||
private func migrateToV4() throws {
|
||||
let existing = try tableColumns("paired_macs")
|
||||
guard !existing.contains("owner_key") else { return }
|
||||
|
||||
try exec("""
|
||||
CREATE TABLE paired_macs_v4 (
|
||||
mac_device_id TEXT NOT NULL,
|
||||
owner_key TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
stack_user_id TEXT,
|
||||
team_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
custom_name TEXT,
|
||||
custom_color TEXT,
|
||||
custom_icon TEXT,
|
||||
PRIMARY KEY (mac_device_id, owner_key)
|
||||
);
|
||||
""")
|
||||
try exec("""
|
||||
INSERT INTO paired_macs_v4 (
|
||||
mac_device_id, owner_key, display_name, stack_user_id, team_id,
|
||||
created_at, last_seen_at, is_active, custom_name, custom_color, custom_icon
|
||||
)
|
||||
SELECT
|
||||
mac_device_id,
|
||||
IFNULL(stack_user_id, '') || char(31) || IFNULL(team_id, ''),
|
||||
display_name,
|
||||
stack_user_id,
|
||||
team_id,
|
||||
created_at,
|
||||
last_seen_at,
|
||||
is_active,
|
||||
custom_name,
|
||||
custom_color,
|
||||
custom_icon
|
||||
FROM paired_macs;
|
||||
""")
|
||||
try exec("""
|
||||
CREATE TABLE mac_routes_v4 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mac_device_id TEXT NOT NULL,
|
||||
owner_key TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
endpoint_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (mac_device_id, owner_key)
|
||||
REFERENCES paired_macs_v4(mac_device_id, owner_key)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
""")
|
||||
try exec("""
|
||||
INSERT INTO mac_routes_v4 (mac_device_id, owner_key, route_id, kind, endpoint_json, priority)
|
||||
SELECT
|
||||
routes.mac_device_id,
|
||||
IFNULL(macs.stack_user_id, '') || char(31) || IFNULL(macs.team_id, ''),
|
||||
routes.route_id,
|
||||
routes.kind,
|
||||
routes.endpoint_json,
|
||||
routes.priority
|
||||
FROM mac_routes routes
|
||||
JOIN paired_macs macs ON macs.mac_device_id = routes.mac_device_id;
|
||||
""")
|
||||
try exec("DROP TABLE mac_routes;")
|
||||
try exec("DROP TABLE paired_macs;")
|
||||
try exec("ALTER TABLE paired_macs_v4 RENAME TO paired_macs;")
|
||||
try exec("ALTER TABLE mac_routes_v4 RENAME TO mac_routes;")
|
||||
try exec("CREATE INDEX IF NOT EXISTS idx_macs_stack_user ON paired_macs(stack_user_id);")
|
||||
try exec("CREATE INDEX IF NOT EXISTS idx_macs_team ON paired_macs(stack_user_id, team_id);")
|
||||
try exec("CREATE INDEX IF NOT EXISTS idx_routes_device ON mac_routes(mac_device_id, owner_key);")
|
||||
}
|
||||
|
||||
/// Column names defined on `table` (via `PRAGMA table_info`), used to make
|
||||
/// additive column migrations idempotent.
|
||||
private func tableColumns(_ table: String) throws -> Set<String> {
|
||||
var statement: OpaquePointer?
|
||||
defer { sqlite3_finalize(statement) }
|
||||
let rc = sqlite3_prepare_v2(db, "PRAGMA table_info(\(table));", -1, &statement, nil)
|
||||
guard rc == SQLITE_OK else {
|
||||
throw MobilePairedMacStoreError.prepareFailed(rc, lastErrorMessage())
|
||||
}
|
||||
var columns: Set<String> = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
// table_info columns: cid(0), name(1), type(2), notnull(3),
|
||||
// dflt_value(4), pk(5).
|
||||
if let name = sqlite3_column_text(statement, 1) {
|
||||
columns.insert(String(cString: name))
|
||||
}
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Insert or update one paired Mac within the explicit account/team owner scope.
|
||||
public func upsert(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
routes: [CmxAttachRoute],
|
||||
markActive: Bool,
|
||||
stackUserID: String?,
|
||||
teamID: String? = nil,
|
||||
now: Date = Date()
|
||||
) throws {
|
||||
try ensureReady()
|
||||
try transaction {
|
||||
if markActive {
|
||||
let scope = stackUserID.map(BindValue.text) ?? .null
|
||||
if stackUserID != nil {
|
||||
try exec("UPDATE paired_macs SET is_active = 0 WHERE stack_user_id IS ?;",
|
||||
binding: [scope])
|
||||
} else {
|
||||
try exec("UPDATE paired_macs SET is_active = 0;")
|
||||
}
|
||||
try clearActiveMacs(stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
let existing = try fetchMacRow(macDeviceID: macDeviceID)
|
||||
let createdAt = existing?.createdAt ?? now
|
||||
let ownerKey = "\(stackUserID ?? "")\u{1F}\(teamID ?? "")"
|
||||
let existing = try fetchMacRow(macDeviceID: macDeviceID, ownerKey: ownerKey)
|
||||
var claimedLegacy: MacRow?
|
||||
if existing == nil,
|
||||
teamID != nil,
|
||||
let legacy = try fetchMacRow(
|
||||
macDeviceID: macDeviceID,
|
||||
ownerKey: "\(stackUserID ?? "")\u{1F}"
|
||||
) {
|
||||
try moveMacRowScope(
|
||||
macDeviceID: macDeviceID,
|
||||
fromOwnerKey: legacy.ownerKey,
|
||||
toOwnerKey: ownerKey,
|
||||
teamID: teamID
|
||||
)
|
||||
claimedLegacy = legacy
|
||||
}
|
||||
let createdAt = existing?.createdAt ?? claimedLegacy?.createdAt ?? now
|
||||
try upsertMacRow(
|
||||
macDeviceID: macDeviceID,
|
||||
ownerKey: ownerKey,
|
||||
displayName: displayName,
|
||||
stackUserID: stackUserID,
|
||||
teamID: teamID,
|
||||
createdAt: createdAt,
|
||||
lastSeenAt: now,
|
||||
isActive: markActive
|
||||
)
|
||||
try exec("DELETE FROM mac_routes WHERE mac_device_id = ?;", binding: [.text(macDeviceID)])
|
||||
try exec(
|
||||
"DELETE FROM mac_routes WHERE mac_device_id = ? AND owner_key = ?;",
|
||||
binding: [.text(macDeviceID), .text(ownerKey)]
|
||||
)
|
||||
for route in routes {
|
||||
let encoded = try Self.encodeRoute(route)
|
||||
try exec("""
|
||||
INSERT INTO mac_routes (mac_device_id, route_id, kind, endpoint_json, priority)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
INSERT INTO mac_routes (mac_device_id, owner_key, route_id, kind, endpoint_json, priority)
|
||||
VALUES (?, ?, ?, ?, ?, ?);
|
||||
""", binding: [
|
||||
.text(macDeviceID),
|
||||
.text(ownerKey),
|
||||
.text(route.id),
|
||||
.text(route.kind.rawValue),
|
||||
.text(encoded),
|
||||
@@ -183,44 +375,78 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
}
|
||||
}
|
||||
|
||||
public func loadAll(stackUserID: String? = nil) throws -> [MobilePairedMac] {
|
||||
/// Load every paired Mac visible to the optional Stack user and team scope.
|
||||
public func loadAll(stackUserID: String? = nil, teamID: String? = nil) throws -> [MobilePairedMac] {
|
||||
try ensureReady()
|
||||
return try fetchAllMacs(stackUserID: stackUserID)
|
||||
return try fetchAllMacs(stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
public func activeMac(stackUserID: String? = nil) throws -> MobilePairedMac? {
|
||||
/// Load the active paired Mac in the optional Stack user and team scope.
|
||||
public func activeMac(stackUserID: String? = nil, teamID: String? = nil) throws -> MobilePairedMac? {
|
||||
try ensureReady()
|
||||
return try fetchAllMacs(activeOnly: true, stackUserID: stackUserID).first
|
||||
return try fetchAllMacs(activeOnly: true, stackUserID: stackUserID, teamID: teamID).first
|
||||
}
|
||||
|
||||
public func setActive(macDeviceID: String) throws {
|
||||
/// Mark one paired Mac active within its explicit account/team owner scope.
|
||||
public func setActive(macDeviceID: String, stackUserID: String? = nil, teamID: String? = nil) throws {
|
||||
try ensureReady()
|
||||
let ownerKey = "\(stackUserID ?? "")\u{1F}\(teamID ?? "")"
|
||||
try transaction {
|
||||
// Clear the active flag only within the target Mac's own Stack-user
|
||||
// scope, mirroring the scoped clear in `upsert`. On a shared device
|
||||
// (more than one Stack user has pairings), switching hosts for one
|
||||
// signed-in user must not wipe another user's active Mac, or that
|
||||
// user fails to auto-reconnect after signing back in. `IS` is
|
||||
// SQLite's null-safe equality, so a NULL-scoped target clears only
|
||||
// other NULL-scoped rows.
|
||||
try exec("""
|
||||
UPDATE paired_macs SET is_active = 0
|
||||
WHERE stack_user_id IS (
|
||||
SELECT stack_user_id FROM paired_macs WHERE mac_device_id = ?
|
||||
);
|
||||
""",
|
||||
binding: [.text(macDeviceID)])
|
||||
try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ?;",
|
||||
binding: [.text(macDeviceID)])
|
||||
try clearActiveMacs(stackUserID: stackUserID, teamID: teamID)
|
||||
try exec("UPDATE paired_macs SET is_active = 1 WHERE mac_device_id = ? AND owner_key = ?;",
|
||||
binding: [.text(macDeviceID), .text(ownerKey)])
|
||||
}
|
||||
}
|
||||
|
||||
public func remove(macDeviceID: String) throws {
|
||||
/// Clear the active paired Mac in the optional Stack user and team scope.
|
||||
public func clearActive(stackUserID: String? = nil, teamID: String? = nil) throws {
|
||||
try ensureReady()
|
||||
try exec("DELETE FROM paired_macs WHERE mac_device_id = ?;",
|
||||
binding: [.text(macDeviceID)])
|
||||
try clearActiveMacs(stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
/// Persist user-facing customizations for one paired Mac.
|
||||
public func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
stackUserID: String? = nil,
|
||||
teamID: String? = nil,
|
||||
now: Date = Date()
|
||||
) throws {
|
||||
try ensureReady()
|
||||
// Bump last_seen_at so the change is the freshest write for this record and
|
||||
// the LWW backup/restore propagates it to the user's other devices. Leaves
|
||||
// display_name / routes / is_active untouched (the Mac owns those).
|
||||
try exec("""
|
||||
UPDATE paired_macs
|
||||
SET custom_name = ?, custom_color = ?, custom_icon = ?, last_seen_at = ?
|
||||
WHERE mac_device_id = ? AND owner_key = ?;
|
||||
""", binding: [
|
||||
customName.map(BindValue.text) ?? .null,
|
||||
customColor.map(BindValue.text) ?? .null,
|
||||
customIcon.map(BindValue.text) ?? .null,
|
||||
.real(now.timeIntervalSince1970),
|
||||
.text(macDeviceID),
|
||||
.text("\(stackUserID ?? "")\u{1F}\(teamID ?? "")"),
|
||||
])
|
||||
}
|
||||
|
||||
/// Remove one paired Mac in a specific owner scope, or all matching legacy rows when unscoped.
|
||||
public func remove(macDeviceID: String, stackUserID: String? = nil, teamID: String? = nil) throws {
|
||||
try ensureReady()
|
||||
if stackUserID == nil && teamID == nil {
|
||||
try exec("DELETE FROM paired_macs WHERE mac_device_id = ?;",
|
||||
binding: [.text(macDeviceID)])
|
||||
} else {
|
||||
try exec(
|
||||
"DELETE FROM paired_macs WHERE mac_device_id = ? AND owner_key = ?;",
|
||||
binding: [.text(macDeviceID), .text("\(stackUserID ?? "")\u{1F}\(teamID ?? "")")]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove every locally stored paired Mac and route.
|
||||
public func removeAll() throws {
|
||||
try ensureReady()
|
||||
try exec("DELETE FROM paired_macs;")
|
||||
@@ -248,25 +474,30 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
|
||||
private struct MacRow {
|
||||
let macDeviceID: String
|
||||
let ownerKey: String
|
||||
let displayName: String?
|
||||
let stackUserID: String?
|
||||
var teamID: String? = nil
|
||||
let createdAt: Date
|
||||
let lastSeenAt: Date
|
||||
let isActive: Bool
|
||||
var customName: String? = nil
|
||||
var customColor: String? = nil
|
||||
var customIcon: String? = nil
|
||||
}
|
||||
|
||||
private func fetchMacRow(macDeviceID: String) throws -> MacRow? {
|
||||
private func fetchMacRow(macDeviceID: String, ownerKey: String) throws -> MacRow? {
|
||||
var statement: OpaquePointer?
|
||||
defer { sqlite3_finalize(statement) }
|
||||
let sql = """
|
||||
SELECT display_name, stack_user_id, created_at, last_seen_at, is_active
|
||||
FROM paired_macs WHERE mac_device_id = ?;
|
||||
SELECT display_name, stack_user_id, created_at, last_seen_at, is_active, team_id
|
||||
FROM paired_macs WHERE mac_device_id = ? AND owner_key = ?;
|
||||
"""
|
||||
let rc = sqlite3_prepare_v2(db, sql, -1, &statement, nil)
|
||||
guard rc == SQLITE_OK else {
|
||||
throw MobilePairedMacStoreError.prepareFailed(rc, lastErrorMessage())
|
||||
}
|
||||
try bind(statement: statement, parameters: [.text(macDeviceID)])
|
||||
try bind(statement: statement, parameters: [.text(macDeviceID), .text(ownerKey)])
|
||||
let step = sqlite3_step(statement)
|
||||
if step == SQLITE_DONE { return nil }
|
||||
guard step == SQLITE_ROW else {
|
||||
@@ -277,10 +508,13 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 2))
|
||||
let lastSeenAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 3))
|
||||
let isActive = sqlite3_column_int(statement, 4) != 0
|
||||
let teamID = Self.readNullableText(statement, column: 5)
|
||||
return MacRow(
|
||||
macDeviceID: macDeviceID,
|
||||
ownerKey: ownerKey,
|
||||
displayName: displayName,
|
||||
stackUserID: stackUserID,
|
||||
teamID: teamID,
|
||||
createdAt: createdAt,
|
||||
lastSeenAt: lastSeenAt,
|
||||
isActive: isActive
|
||||
@@ -289,31 +523,96 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
|
||||
private func upsertMacRow(
|
||||
macDeviceID: String,
|
||||
ownerKey: String,
|
||||
displayName: String?,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
createdAt: Date,
|
||||
lastSeenAt: Date,
|
||||
isActive: Bool
|
||||
) throws {
|
||||
try exec("""
|
||||
INSERT INTO paired_macs (mac_device_id, display_name, stack_user_id, created_at, last_seen_at, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mac_device_id) DO UPDATE SET
|
||||
INSERT INTO paired_macs (mac_device_id, owner_key, display_name, stack_user_id, team_id, created_at, last_seen_at, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(mac_device_id, owner_key) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
stack_user_id = excluded.stack_user_id,
|
||||
team_id = excluded.team_id,
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
is_active = excluded.is_active;
|
||||
""", binding: [
|
||||
.text(macDeviceID),
|
||||
.text(ownerKey),
|
||||
displayName.map(BindValue.text) ?? .null,
|
||||
stackUserID.map(BindValue.text) ?? .null,
|
||||
teamID.map(BindValue.text) ?? .null,
|
||||
.real(createdAt.timeIntervalSince1970),
|
||||
.real(lastSeenAt.timeIntervalSince1970),
|
||||
.int(isActive ? 1 : 0),
|
||||
])
|
||||
}
|
||||
|
||||
private func fetchAllMacs(activeOnly: Bool = false, stackUserID: String? = nil) throws -> [MobilePairedMac] {
|
||||
private func clearActiveMacs(stackUserID: String?, teamID: String?) throws {
|
||||
let stackBinding = stackUserID.map(BindValue.text) ?? .null
|
||||
if let teamID {
|
||||
// The visible team scope includes legacy NULL-team rows until their
|
||||
// next upsert claims them, so they must share the same active-row
|
||||
// invariant as explicit team rows.
|
||||
try exec("""
|
||||
UPDATE paired_macs SET is_active = 0
|
||||
WHERE stack_user_id IS ? AND (team_id IS ? OR team_id IS NULL);
|
||||
""", binding: [stackBinding, .text(teamID)])
|
||||
} else {
|
||||
try exec("""
|
||||
UPDATE paired_macs SET is_active = 0
|
||||
WHERE stack_user_id IS ? AND team_id IS NULL;
|
||||
""", binding: [stackBinding])
|
||||
}
|
||||
}
|
||||
|
||||
private func moveMacRowScope(
|
||||
macDeviceID: String,
|
||||
fromOwnerKey: String,
|
||||
toOwnerKey: String,
|
||||
teamID: String?
|
||||
) throws {
|
||||
try exec("""
|
||||
INSERT INTO paired_macs (
|
||||
mac_device_id, owner_key, display_name, stack_user_id, team_id,
|
||||
created_at, last_seen_at, is_active, custom_name, custom_color, custom_icon
|
||||
)
|
||||
SELECT
|
||||
mac_device_id, ?, display_name, stack_user_id, ?, created_at,
|
||||
last_seen_at, is_active, custom_name, custom_color, custom_icon
|
||||
FROM paired_macs
|
||||
WHERE mac_device_id = ? AND owner_key = ?;
|
||||
""", binding: [
|
||||
.text(toOwnerKey),
|
||||
teamID.map(BindValue.text) ?? .null,
|
||||
.text(macDeviceID),
|
||||
.text(fromOwnerKey),
|
||||
])
|
||||
try exec("""
|
||||
UPDATE mac_routes
|
||||
SET owner_key = ?
|
||||
WHERE mac_device_id = ? AND owner_key = ?;
|
||||
""", binding: [
|
||||
.text(toOwnerKey),
|
||||
.text(macDeviceID),
|
||||
.text(fromOwnerKey),
|
||||
])
|
||||
try exec("""
|
||||
DELETE FROM paired_macs
|
||||
WHERE mac_device_id = ? AND owner_key = ?;
|
||||
""", binding: [
|
||||
.text(macDeviceID),
|
||||
.text(fromOwnerKey),
|
||||
])
|
||||
}
|
||||
|
||||
private func fetchAllMacs(
|
||||
activeOnly: Bool = false, stackUserID: String? = nil, teamID: String? = nil
|
||||
) throws -> [MobilePairedMac] {
|
||||
var statement: OpaquePointer?
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var clauses: [String] = []
|
||||
@@ -325,9 +624,17 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
clauses.append("stack_user_id IS ?")
|
||||
bindings.append(.text(stackUserID))
|
||||
}
|
||||
if let teamID {
|
||||
// Legacy-visibility: a NULL-team row (pre-v3 upgrade, or anonymous
|
||||
// pairing) is visible under EVERY team so an upgrade never hides an
|
||||
// existing host; it is stamped with the active team on the next upsert.
|
||||
clauses.append("(team_id IS ? OR team_id IS NULL)")
|
||||
bindings.append(.text(teamID))
|
||||
}
|
||||
let whereClause = clauses.isEmpty ? "" : "WHERE " + clauses.joined(separator: " AND ")
|
||||
let sql = """
|
||||
SELECT mac_device_id, display_name, stack_user_id, created_at, last_seen_at, is_active
|
||||
SELECT mac_device_id, owner_key, display_name, stack_user_id, created_at, last_seen_at, is_active,
|
||||
custom_name, custom_color, custom_icon, team_id
|
||||
FROM paired_macs
|
||||
\(whereClause)
|
||||
ORDER BY last_seen_at DESC;
|
||||
@@ -341,23 +648,30 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let cString = sqlite3_column_text(statement, 0) else { continue }
|
||||
let macDeviceID = String(cString: cString)
|
||||
let displayName = Self.readNullableText(statement, column: 1)
|
||||
let storedStackUserID = Self.readNullableText(statement, column: 2)
|
||||
let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 3))
|
||||
let lastSeenAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 4))
|
||||
let isActive = sqlite3_column_int(statement, 5) != 0
|
||||
guard let ownerCString = sqlite3_column_text(statement, 1) else { continue }
|
||||
let ownerKey = String(cString: ownerCString)
|
||||
let displayName = Self.readNullableText(statement, column: 2)
|
||||
let storedStackUserID = Self.readNullableText(statement, column: 3)
|
||||
let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 4))
|
||||
let lastSeenAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 5))
|
||||
let isActive = sqlite3_column_int(statement, 6) != 0
|
||||
rows.append(MacRow(
|
||||
macDeviceID: macDeviceID,
|
||||
ownerKey: ownerKey,
|
||||
displayName: displayName,
|
||||
stackUserID: storedStackUserID,
|
||||
teamID: Self.readNullableText(statement, column: 10),
|
||||
createdAt: createdAt,
|
||||
lastSeenAt: lastSeenAt,
|
||||
isActive: isActive
|
||||
isActive: isActive,
|
||||
customName: Self.readNullableText(statement, column: 7),
|
||||
customColor: Self.readNullableText(statement, column: 8),
|
||||
customIcon: Self.readNullableText(statement, column: 9)
|
||||
))
|
||||
}
|
||||
|
||||
return try rows.map { row in
|
||||
let routes = try fetchRoutes(macDeviceID: row.macDeviceID)
|
||||
let routes = try fetchRoutes(macDeviceID: row.macDeviceID, ownerKey: row.ownerKey)
|
||||
return MobilePairedMac(
|
||||
macDeviceID: row.macDeviceID,
|
||||
displayName: row.displayName,
|
||||
@@ -365,25 +679,29 @@ public actor MobilePairedMacStore: MobilePairedMacStoring {
|
||||
createdAt: row.createdAt,
|
||||
lastSeenAt: row.lastSeenAt,
|
||||
isActive: row.isActive,
|
||||
stackUserID: row.stackUserID
|
||||
stackUserID: row.stackUserID,
|
||||
teamID: row.teamID,
|
||||
customName: row.customName,
|
||||
customColor: row.customColor,
|
||||
customIcon: row.customIcon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchRoutes(macDeviceID: String) throws -> [CmxAttachRoute] {
|
||||
private func fetchRoutes(macDeviceID: String, ownerKey: String) throws -> [CmxAttachRoute] {
|
||||
var statement: OpaquePointer?
|
||||
defer { sqlite3_finalize(statement) }
|
||||
let sql = """
|
||||
SELECT endpoint_json
|
||||
FROM mac_routes
|
||||
WHERE mac_device_id = ?
|
||||
WHERE mac_device_id = ? AND owner_key = ?
|
||||
ORDER BY priority ASC, id ASC;
|
||||
"""
|
||||
let rc = sqlite3_prepare_v2(db, sql, -1, &statement, nil)
|
||||
guard rc == SQLITE_OK else {
|
||||
throw MobilePairedMacStoreError.prepareFailed(rc, lastErrorMessage())
|
||||
}
|
||||
try bind(statement: statement, parameters: [.text(macDeviceID)])
|
||||
try bind(statement: statement, parameters: [.text(macDeviceID), .text(ownerKey)])
|
||||
|
||||
var routes: [CmxAttachRoute] = []
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
+126
-23
@@ -14,6 +14,9 @@ public protocol MobilePairedMacStoring: Sendable {
|
||||
/// - routes: Attach routes advertised by the Mac.
|
||||
/// - markActive: When `true`, makes this the active pairing for its scope.
|
||||
/// - stackUserID: Owning Stack Auth user, if any.
|
||||
/// - teamID: Stack team this pairing belongs to; stamped on the row so the
|
||||
/// local list can be scoped per team. `nil` leaves the team unset (anonymous
|
||||
/// / pre-team pairing).
|
||||
/// - now: Timestamp used for `lastSeenAt` (and `createdAt` on first insert).
|
||||
func upsert(
|
||||
macDeviceID: String,
|
||||
@@ -21,38 +24,94 @@ public protocol MobilePairedMacStoring: Sendable {
|
||||
routes: [CmxAttachRoute],
|
||||
markActive: Bool,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws
|
||||
|
||||
/// Load all paired Macs, optionally scoped to a Stack user.
|
||||
/// - Parameter stackUserID: When set, returns only Macs owned by that user.
|
||||
/// Load all paired Macs, optionally scoped to a Stack user and team.
|
||||
/// - Parameters:
|
||||
/// - stackUserID: When set, returns only Macs owned by that user.
|
||||
/// - teamID: When set, returns only Macs in that team (plus team-less legacy
|
||||
/// rows, so an upgrade never hides existing hosts). `nil` = every team.
|
||||
/// - Returns: Paired Macs ordered by `lastSeenAt` descending.
|
||||
func loadAll(stackUserID: String?) async throws -> [MobilePairedMac]
|
||||
func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac]
|
||||
|
||||
/// Return the active paired Mac for a scope, if any.
|
||||
/// - Parameter stackUserID: When set, scopes the lookup to that user.
|
||||
func activeMac(stackUserID: String?) async throws -> MobilePairedMac?
|
||||
/// - Parameters:
|
||||
/// - stackUserID: When set, scopes the lookup to that user.
|
||||
/// - teamID: When set, scopes the lookup to that team (plus team-less rows).
|
||||
func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac?
|
||||
|
||||
/// Mark the given Mac as the single active pairing.
|
||||
/// - Parameter macDeviceID: Mac to activate.
|
||||
func setActive(macDeviceID: String) async throws
|
||||
/// Mark the given Mac as the single active pairing in one owner scope.
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Mac to activate.
|
||||
/// - stackUserID: Owning Stack Auth user, if any.
|
||||
/// - teamID: Stack team this activation belongs to, if any.
|
||||
func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws
|
||||
|
||||
/// Remove a single paired Mac.
|
||||
/// - Parameter macDeviceID: Mac to forget.
|
||||
func remove(macDeviceID: String) async throws
|
||||
/// Clear the active pairing for one visible owner scope.
|
||||
/// - Parameters:
|
||||
/// - stackUserID: Owning Stack Auth user, if any.
|
||||
/// - teamID: Stack team whose visible rows should be cleared. When set,
|
||||
/// team-less legacy rows are cleared too because they are visible in that
|
||||
/// team scope.
|
||||
func clearActive(stackUserID: String?, teamID: String?) async throws
|
||||
|
||||
/// Set the user's per-Mac customizations (synced per user). Leaves the
|
||||
/// Mac-reported name, routes, and active flag untouched, and bumps
|
||||
/// `lastSeenAt` so the change is the freshest write for LWW sync.
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Mac to customize.
|
||||
/// - customName: Name override, or `nil` to clear it.
|
||||
/// - customColor: Color override (`"palette:<n>"` / `"#RRGGBB"`), or `nil`.
|
||||
/// - customIcon: Icon override (SF Symbol name or emoji), or `nil`.
|
||||
/// - now: Timestamp for `lastSeenAt`.
|
||||
func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws
|
||||
|
||||
/// Remove a single paired Mac in one owner scope.
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Mac to forget.
|
||||
/// - stackUserID: Owning Stack Auth user, if any.
|
||||
/// - teamID: Stack team this pairing belongs to, if any.
|
||||
func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws
|
||||
|
||||
/// Remove all paired Macs.
|
||||
func removeAll() async throws
|
||||
}
|
||||
|
||||
extension MobilePairedMacStoring {
|
||||
/// Insert or update a paired Mac, timestamping with the current `Date`.
|
||||
/// - Parameters:
|
||||
/// - macDeviceID: Stable identifier of the Mac.
|
||||
/// - displayName: Optional human-readable Mac name.
|
||||
/// - routes: Attach routes advertised by the Mac.
|
||||
/// - markActive: When `true`, makes this the active pairing for its scope.
|
||||
/// - stackUserID: Owning Stack Auth user, if any.
|
||||
/// Insert or update a paired Mac with an explicit timestamp but no team scope
|
||||
/// (`teamID: nil`). Keeps existing call sites compiling; the team-aware caller
|
||||
/// (``BackingUpPairedMacStore``) injects the team via the full requirement.
|
||||
public func upsert(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
routes: [CmxAttachRoute],
|
||||
markActive: Bool,
|
||||
stackUserID: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
try await upsert(
|
||||
macDeviceID: macDeviceID,
|
||||
displayName: displayName,
|
||||
routes: routes,
|
||||
markActive: markActive,
|
||||
stackUserID: stackUserID,
|
||||
teamID: nil,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
/// Insert or update a paired Mac, timestamping with the current `Date` and no
|
||||
/// team scope.
|
||||
public func upsert(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
@@ -66,17 +125,61 @@ extension MobilePairedMacStoring {
|
||||
routes: routes,
|
||||
markActive: markActive,
|
||||
stackUserID: stackUserID,
|
||||
teamID: nil,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
|
||||
/// Load all paired Macs across every Stack user scope.
|
||||
public func loadAll() async throws -> [MobilePairedMac] {
|
||||
try await loadAll(stackUserID: nil)
|
||||
/// Load all paired Macs for a Stack user across every team.
|
||||
public func loadAll(stackUserID: String?) async throws -> [MobilePairedMac] {
|
||||
try await loadAll(stackUserID: stackUserID, teamID: nil)
|
||||
}
|
||||
|
||||
/// Return the active paired Mac across every Stack user scope, if any.
|
||||
/// Load all paired Macs across every Stack user and team scope.
|
||||
public func loadAll() async throws -> [MobilePairedMac] {
|
||||
try await loadAll(stackUserID: nil, teamID: nil)
|
||||
}
|
||||
|
||||
/// Return the active paired Mac for a Stack user across every team, if any.
|
||||
public func activeMac(stackUserID: String?) async throws -> MobilePairedMac? {
|
||||
try await activeMac(stackUserID: stackUserID, teamID: nil)
|
||||
}
|
||||
|
||||
/// Return the active paired Mac across every Stack user and team scope, if any.
|
||||
public func activeMac() async throws -> MobilePairedMac? {
|
||||
try await activeMac(stackUserID: nil)
|
||||
try await activeMac(stackUserID: nil, teamID: nil)
|
||||
}
|
||||
|
||||
/// Mark the given Mac active without an explicit owner scope. Implementations
|
||||
/// may use this only for legacy/unscoped rows; team-aware callers should pass
|
||||
/// the captured scope through the full requirement.
|
||||
public func setActive(macDeviceID: String) async throws {
|
||||
try await setActive(macDeviceID: macDeviceID, stackUserID: nil, teamID: nil)
|
||||
}
|
||||
|
||||
/// Persist customizations without an explicit owner scope. Team-aware callers
|
||||
/// should pass the captured scope through the full requirement.
|
||||
public func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
try await setCustomization(
|
||||
macDeviceID: macDeviceID,
|
||||
customName: customName,
|
||||
customColor: customColor,
|
||||
customIcon: customIcon,
|
||||
stackUserID: nil,
|
||||
teamID: nil,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove a Mac without an explicit owner scope. Team-aware callers should
|
||||
/// pass the captured scope through the full requirement.
|
||||
public func remove(macDeviceID: String) async throws {
|
||||
try await remove(macDeviceID: macDeviceID, stackUserID: nil, teamID: nil)
|
||||
}
|
||||
}
|
||||
|
||||
+339
-1
@@ -1,5 +1,6 @@
|
||||
import CMUXMobileCore
|
||||
import Foundation
|
||||
import SQLite3
|
||||
import Testing
|
||||
@testable import CmuxMobilePairedMac
|
||||
|
||||
@@ -95,7 +96,7 @@ import Testing
|
||||
try await store.upsert(macDeviceID: "mac-b", displayName: nil, routes: [route], markActive: true, stackUserID: "user-2", now: Date())
|
||||
|
||||
// Switching user-1's active Mac must not disturb user-2's active pairing.
|
||||
try await store.setActive(macDeviceID: "mac-a1")
|
||||
try await store.setActive(macDeviceID: "mac-a1", stackUserID: "user-1", teamID: nil)
|
||||
|
||||
let activeUser1 = try await store.loadAll(stackUserID: "user-1").filter(\.isActive)
|
||||
#expect(activeUser1.map(\.macDeviceID) == ["mac-a1"])
|
||||
@@ -132,4 +133,341 @@ import Testing
|
||||
let all = try await reopened.loadAll()
|
||||
#expect(all.isEmpty)
|
||||
}
|
||||
|
||||
/// A newer build can bump `PRAGMA user_version` above what this build knows.
|
||||
/// Because schema migrations are additive (older builds keep reading the
|
||||
/// columns/tables they know), opening that database from an older build must
|
||||
/// still return the saved Macs, not strand the whole store and surface as a
|
||||
/// total loss of the user's paired hosts on a downgrade/cross-build open.
|
||||
@Test func futureSchemaVersionStillReadsExistingMacs() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("paired-macs.sqlite3")
|
||||
|
||||
// A hand-typed manual host route (the store treats it like any other);
|
||||
// `.tailscale` is a valid host/port kind, as in the other tests.
|
||||
let route = try CmxAttachRoute(
|
||||
id: "manual",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "192.168.1.50", port: 22)
|
||||
)
|
||||
|
||||
// Seed the store at the current schema version with one manual host.
|
||||
do {
|
||||
let store = try MobilePairedMacStore(databaseURL: url)
|
||||
try await store.upsert(
|
||||
macDeviceID: "manual-192.168.1.50:22",
|
||||
displayName: "Studio",
|
||||
routes: [route],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
|
||||
// Simulate a future build that wrote an additive schema and bumped the
|
||||
// version beyond this build's understanding.
|
||||
var handle: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &handle) == SQLITE_OK)
|
||||
let futureVersion = MobilePairedMacStore.currentSchemaVersion + 98
|
||||
#expect(
|
||||
sqlite3_exec(handle, "PRAGMA user_version = \(futureVersion);", nil, nil, nil) == SQLITE_OK
|
||||
)
|
||||
sqlite3_close(handle)
|
||||
|
||||
// The current build must degrade gracefully and still read the host.
|
||||
let reopened = try MobilePairedMacStore(databaseURL: url)
|
||||
let all = try await reopened.loadAll(stackUserID: "user-1")
|
||||
#expect(all.map(\.macDeviceID) == ["manual-192.168.1.50:22"])
|
||||
#expect(all.first?.routes.first?.endpoint == .hostPort(host: "192.168.1.50", port: 22))
|
||||
|
||||
// And it must NOT have written a destructive downgrade marker: the on-disk
|
||||
// schema version is left exactly as the newer build set it.
|
||||
var check: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &check) == SQLITE_OK)
|
||||
var stmt: OpaquePointer?
|
||||
#expect(sqlite3_prepare_v2(check, "PRAGMA user_version;", -1, &stmt, nil) == SQLITE_OK)
|
||||
#expect(sqlite3_step(stmt) == SQLITE_ROW)
|
||||
#expect(sqlite3_column_int(stmt, 0) == futureVersion)
|
||||
sqlite3_finalize(stmt)
|
||||
sqlite3_close(check)
|
||||
}
|
||||
|
||||
@Test func partialV2MigrationRecoversWithoutDuplicateColumn() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("paired-macs.sqlite3")
|
||||
|
||||
// Simulate a device left half-migrated by an earlier, non-transactional
|
||||
// build of the v2 migration: the v1 table exists with ONE of the three
|
||||
// additive custom columns added, but `user_version` is still 1 (the bump
|
||||
// never ran). The old code would re-run `ADD COLUMN custom_name` here and
|
||||
// fail with a duplicate-column error, bricking the store. The fixed
|
||||
// migration must add only the missing columns and finish.
|
||||
var handle: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &handle) == SQLITE_OK)
|
||||
let seed = """
|
||||
CREATE TABLE paired_macs (
|
||||
mac_device_id TEXT PRIMARY KEY NOT NULL,
|
||||
display_name TEXT,
|
||||
stack_user_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_macs_stack_user ON paired_macs(stack_user_id);
|
||||
CREATE TABLE mac_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mac_device_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
endpoint_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (mac_device_id) REFERENCES paired_macs(mac_device_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_routes_device ON mac_routes(mac_device_id);
|
||||
ALTER TABLE paired_macs ADD COLUMN custom_name TEXT;
|
||||
INSERT INTO paired_macs
|
||||
(mac_device_id, display_name, stack_user_id, created_at, last_seen_at, is_active, custom_name)
|
||||
VALUES ('mac-1', 'Studio', 'user-1', 0, 0, 1, 'My Studio');
|
||||
PRAGMA user_version = 1;
|
||||
"""
|
||||
#expect(sqlite3_exec(handle, seed, nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close(handle)
|
||||
|
||||
// First read triggers the lazy migration. It must complete (add the
|
||||
// missing custom_color / custom_icon columns) without a duplicate-column
|
||||
// failure on the already-present custom_name, and preserve the saved data.
|
||||
let reopened = try MobilePairedMacStore(databaseURL: url)
|
||||
let all = try await reopened.loadAll(stackUserID: "user-1")
|
||||
#expect(all.count == 1)
|
||||
#expect(all.first?.customName == "My Studio")
|
||||
#expect(all.first?.customColor == nil)
|
||||
#expect(all.first?.customIcon == nil)
|
||||
|
||||
// The newly-added columns are usable: a customization write/read round-trips.
|
||||
try await reopened.setCustomization(
|
||||
macDeviceID: "mac-1",
|
||||
customName: "My Studio",
|
||||
customColor: "palette:3",
|
||||
customIcon: "🛠️",
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: Date()
|
||||
)
|
||||
let updated = try await reopened.loadAll(stackUserID: "user-1")
|
||||
#expect(updated.first?.customColor == "palette:3")
|
||||
#expect(updated.first?.customIcon == "🛠️")
|
||||
|
||||
var check: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &check) == SQLITE_OK)
|
||||
var stmt: OpaquePointer?
|
||||
#expect(sqlite3_prepare_v2(check, "PRAGMA user_version;", -1, &stmt, nil) == SQLITE_OK)
|
||||
#expect(sqlite3_step(stmt) == SQLITE_ROW)
|
||||
// Opening also ran v2→v3 (team_id), so the final version is the current one.
|
||||
#expect(sqlite3_column_int(stmt, 0) == MobilePairedMacStore.currentSchemaVersion)
|
||||
sqlite3_finalize(stmt)
|
||||
sqlite3_close(check)
|
||||
}
|
||||
|
||||
@Test func migratesV2DatabaseToV3KeepingLegacyRowsVisibleUnderAnyTeam() async throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("paired-macs.sqlite3")
|
||||
|
||||
// Seed a complete v2 schema (paired_macs with the v2 custom columns +
|
||||
// mac_routes) at user_version 2, with one row that has NO team_id column.
|
||||
var handle: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &handle) == SQLITE_OK)
|
||||
let seed = """
|
||||
CREATE TABLE paired_macs (
|
||||
mac_device_id TEXT PRIMARY KEY NOT NULL,
|
||||
display_name TEXT,
|
||||
stack_user_id TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
last_seen_at REAL NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
custom_name TEXT, custom_color TEXT, custom_icon TEXT
|
||||
);
|
||||
CREATE INDEX idx_macs_stack_user ON paired_macs(stack_user_id);
|
||||
CREATE TABLE mac_routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mac_device_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
endpoint_json TEXT NOT NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (mac_device_id) REFERENCES paired_macs(mac_device_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO paired_macs
|
||||
(mac_device_id, display_name, stack_user_id, created_at, last_seen_at, is_active)
|
||||
VALUES ('legacy-mac', 'Old Studio', 'user-1', 0, 0, 1);
|
||||
PRAGMA user_version = 2;
|
||||
"""
|
||||
#expect(sqlite3_exec(handle, seed, nil, nil, nil) == SQLITE_OK)
|
||||
sqlite3_close(handle)
|
||||
|
||||
// Opening runs v2→v3 (adds team_id). The legacy NULL-team row must remain
|
||||
// visible under ANY team (an upgrade never hides existing hosts), and the
|
||||
// on-disk schema version must advance to 3.
|
||||
let store = try MobilePairedMacStore(databaseURL: url)
|
||||
let underTeamA = try await store.loadAll(stackUserID: "user-1", teamID: "team-a")
|
||||
#expect(underTeamA.map(\.macDeviceID) == ["legacy-mac"])
|
||||
#expect(underTeamA.first?.teamID == nil)
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-b")?.macDeviceID == "legacy-mac")
|
||||
|
||||
var check: OpaquePointer?
|
||||
#expect(sqlite3_open(url.path, &check) == SQLITE_OK)
|
||||
var stmt: OpaquePointer?
|
||||
#expect(sqlite3_prepare_v2(check, "PRAGMA user_version;", -1, &stmt, nil) == SQLITE_OK)
|
||||
#expect(sqlite3_step(stmt) == SQLITE_ROW)
|
||||
#expect(sqlite3_column_int(stmt, 0) == MobilePairedMacStore.currentSchemaVersion)
|
||||
sqlite3_finalize(stmt)
|
||||
sqlite3_close(check)
|
||||
}
|
||||
|
||||
@Test func loadAllAndSetActiveAreScopedPerTeam() async throws {
|
||||
let (store, directory) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let routeA = try CmxAttachRoute(id: "a", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.1", port: 22))
|
||||
let routeB = try CmxAttachRoute(id: "b", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.2", port: 22))
|
||||
// Same account, two teams, one active Mac each.
|
||||
try await store.upsert(macDeviceID: "mac-a", displayName: "A", routes: [routeA],
|
||||
markActive: true, stackUserID: "user-1", teamID: "team-a", now: Date())
|
||||
try await store.upsert(macDeviceID: "mac-b", displayName: "B", routes: [routeB],
|
||||
markActive: true, stackUserID: "user-1", teamID: "team-b", now: Date())
|
||||
|
||||
// Each team sees only its own Mac.
|
||||
#expect(try await store.loadAll(stackUserID: "user-1", teamID: "team-a").map(\.macDeviceID) == ["mac-a"])
|
||||
#expect(try await store.loadAll(stackUserID: "user-1", teamID: "team-b").map(\.macDeviceID) == ["mac-b"])
|
||||
// Each team has its own active (activating B did NOT clear A).
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-a")?.macDeviceID == "mac-a")
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-b")?.macDeviceID == "mac-b")
|
||||
// No team filter sees both.
|
||||
#expect(Set(try await store.loadAll(stackUserID: "user-1").map(\.macDeviceID)) == ["mac-a", "mac-b"])
|
||||
|
||||
// setActive on a second Mac added to team-a deactivates only team-a's Mac.
|
||||
try await store.upsert(macDeviceID: "mac-a2", displayName: "A2", routes: [routeA],
|
||||
markActive: false, stackUserID: "user-1", teamID: "team-a", now: Date())
|
||||
try await store.setActive(macDeviceID: "mac-a2", stackUserID: "user-1", teamID: "team-a")
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-a")?.macDeviceID == "mac-a2")
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-b")?.macDeviceID == "mac-b")
|
||||
}
|
||||
|
||||
@Test func claimingLegacyTeamlessMacMovesRoutesWithoutForeignKeyFailure() async throws {
|
||||
let (store, directory) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let legacyRoute = try CmxAttachRoute(
|
||||
id: "legacy",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "10.0.0.10", port: 22)
|
||||
)
|
||||
let updatedRoute = try CmxAttachRoute(
|
||||
id: "updated",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "10.0.0.11", port: 22)
|
||||
)
|
||||
|
||||
try await store.upsert(
|
||||
macDeviceID: "legacy-mac",
|
||||
displayName: "Legacy",
|
||||
routes: [legacyRoute],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
|
||||
try await store.upsert(
|
||||
macDeviceID: "legacy-mac",
|
||||
displayName: "Claimed",
|
||||
routes: [updatedRoute],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: "team-a",
|
||||
now: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
let claimed = try await store.loadAll(stackUserID: "user-1", teamID: "team-a")
|
||||
#expect(claimed.map(\.macDeviceID) == ["legacy-mac"])
|
||||
#expect(claimed.first?.teamID == "team-a")
|
||||
#expect(claimed.first?.routes.map(\.id) == ["updated"])
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-a")?.routes.map(\.id) == ["updated"])
|
||||
}
|
||||
|
||||
@Test func activatingTeamMacClearsVisibleLegacyActiveMac() async throws {
|
||||
let (store, directory) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let legacyRoute = try CmxAttachRoute(id: "legacy", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.10", port: 22))
|
||||
let teamRoute = try CmxAttachRoute(id: "team", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.20", port: 22))
|
||||
try await store.upsert(
|
||||
macDeviceID: "legacy-mac",
|
||||
displayName: "Legacy",
|
||||
routes: [legacyRoute],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
|
||||
try await store.upsert(
|
||||
macDeviceID: "team-mac",
|
||||
displayName: "Team",
|
||||
routes: [teamRoute],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: "team-a",
|
||||
now: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
let visible = try await store.loadAll(stackUserID: "user-1", teamID: "team-a")
|
||||
#expect(visible.filter(\.isActive).map(\.macDeviceID) == ["team-mac"])
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-a")?.macDeviceID == "team-mac")
|
||||
}
|
||||
|
||||
@Test func sameMacDeviceIDCanExistInMultipleTeams() async throws {
|
||||
let (store, directory) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let routeA = try CmxAttachRoute(id: "a", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.1", port: 22))
|
||||
let routeB = try CmxAttachRoute(id: "b", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.2", port: 22))
|
||||
|
||||
try await store.upsert(macDeviceID: "shared-mac", displayName: "Team A Mac", routes: [routeA],
|
||||
markActive: true, stackUserID: "user-1", teamID: "team-a", now: Date(timeIntervalSince1970: 1))
|
||||
try await store.setCustomization(
|
||||
macDeviceID: "shared-mac",
|
||||
customName: "A custom",
|
||||
customColor: "palette:1",
|
||||
customIcon: "desktopcomputer",
|
||||
stackUserID: "user-1",
|
||||
teamID: "team-a",
|
||||
now: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
try await store.upsert(macDeviceID: "shared-mac", displayName: "Team B Mac", routes: [routeB],
|
||||
markActive: true, stackUserID: "user-1", teamID: "team-b", now: Date(timeIntervalSince1970: 3))
|
||||
|
||||
let teamA = try await store.loadAll(stackUserID: "user-1", teamID: "team-a")
|
||||
let teamB = try await store.loadAll(stackUserID: "user-1", teamID: "team-b")
|
||||
|
||||
#expect(teamA.map(\.macDeviceID) == ["shared-mac"])
|
||||
#expect(teamB.map(\.macDeviceID) == ["shared-mac"])
|
||||
#expect(teamA.first?.displayName == "Team A Mac")
|
||||
#expect(teamB.first?.displayName == "Team B Mac")
|
||||
#expect(teamA.first?.routes.first?.id == "a")
|
||||
#expect(teamB.first?.routes.first?.id == "b")
|
||||
#expect(teamA.first?.customColor == "palette:1")
|
||||
#expect(teamB.first?.customColor == nil)
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-a")?.routes.first?.id == "a")
|
||||
#expect(try await store.activeMac(stackUserID: "user-1", teamID: "team-b")?.routes.first?.id == "b")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
public import CMUXMobileCore
|
||||
public import CmuxMobilePairedMac
|
||||
public import Foundation
|
||||
|
||||
/// A ``MobilePairedMacStoring`` decorator that keeps the per-user Durable Object
|
||||
/// backup in sync with the local store, and restores from it on sign-in. Wraps
|
||||
/// the real ``MobilePairedMacStore`` at the composition root behind the
|
||||
/// ``MobilePairedMacBackup`` flag, so EVERY paired-Mac mutation (route refresh,
|
||||
/// pairing, rename, forget, active switch) flows through one seam — no per-call-
|
||||
/// site patching.
|
||||
///
|
||||
/// - Writes (`upsert`/`remove`/`setActive`) forward to the local store first (it
|
||||
/// stays authoritative), then mirror the change to the DO best-effort.
|
||||
/// - Reads (`loadAll`/`activeMac`) trigger a one-time restore for the signed-in
|
||||
/// (account, team) scope before returning, so a fresh install / post-upgrade
|
||||
/// launch shows the user's saved hosts as soon as the host list is read.
|
||||
/// - `removeAll` (the sign-out wipe) is NOT mirrored (signing out must not delete
|
||||
/// the account's server backup) and resets the restore memo so a same-launch
|
||||
/// re-sign-in restores again.
|
||||
public actor BackingUpPairedMacStore: MobilePairedMacStoring, PairedMacBackupRefreshing {
|
||||
private let inner: any MobilePairedMacStoring
|
||||
private let backup: any PairedMacBackingUp
|
||||
/// The current team id, read live so the restore is scoped per (account,
|
||||
/// team): the backup DO is per-team, so switching teams must re-restore.
|
||||
private let teamIDProvider: @Sendable () async -> String?
|
||||
|
||||
/// (account, team) scopes whose restore has SUCCESSFULLY completed this
|
||||
/// process, so a restore runs at most once per scope — but a fetch failure
|
||||
/// is not memoized, so a transient failure retries on the next read.
|
||||
private var restoredScopes: Set<String> = []
|
||||
/// In-flight restores keyed by scope, so concurrent reads await the SAME
|
||||
/// merge instead of one slipping past `restoredScopes` and reading a
|
||||
/// half-restored store.
|
||||
private var inFlight: [String: Task<RestoreOutcome, Never>] = [:]
|
||||
/// The most recent signed-in account seen on a read/write, so `remove` (which
|
||||
/// has no account parameter) only mirrors deletes while signed in.
|
||||
private var lastSignedInAccount: String?
|
||||
private let restoreBoundary: PairedMacRestoreBoundary
|
||||
private let pendingDeleteStore: any PairedMacPendingDeleteStoring
|
||||
private var pendingDeleteIDsByScope: [String: Set<String>] = [:]
|
||||
/// Bumped by every `removeAll()` (sign-out wipe). A restore captures it before
|
||||
/// awaiting its task and re-checks after: a restore that completed/resumed
|
||||
/// across a wipe must NOT memoize `restoredScopes` (which would make a
|
||||
/// same-launch re-sign-in skip the restore and show an empty list) or clobber
|
||||
/// a post-wipe `inFlight` entry.
|
||||
private var resetGeneration = 0
|
||||
|
||||
/// Wrap a local paired-Mac store with a backup transport.
|
||||
public init(
|
||||
inner: any MobilePairedMacStoring,
|
||||
backup: any PairedMacBackingUp,
|
||||
teamIDProvider: @escaping @Sendable () async -> String? = { nil },
|
||||
restoreBoundary: PairedMacRestoreBoundary = PairedMacRestoreBoundary(),
|
||||
pendingDeleteStore: any PairedMacPendingDeleteStoring = InMemoryPairedMacPendingDeleteStore()
|
||||
) {
|
||||
self.inner = inner
|
||||
self.backup = backup
|
||||
self.teamIDProvider = teamIDProvider
|
||||
self.restoreBoundary = restoreBoundary
|
||||
self.pendingDeleteStore = pendingDeleteStore
|
||||
}
|
||||
|
||||
/// Upsert a paired Mac locally, then mirror the changed backup records.
|
||||
public func upsert(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
routes: [CmxAttachRoute],
|
||||
markActive: Bool,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
// Inject the current team (callers go through the no-team convenience
|
||||
// overload, so `teamID` arrives nil) so the local row is scoped to the team
|
||||
// it was paired under. An explicit teamID (e.g. from restore) wins.
|
||||
let team = await resolvedTeam(teamID)
|
||||
// Capture the host that is active BEFORE this upsert, so a `markActive`
|
||||
// upsert can mirror exactly the two records whose active flag changes (the
|
||||
// new host, and the previously-active one now cleared) instead of the whole
|
||||
// account. Scoped to the current team — single-active is per (account, team).
|
||||
let previouslyActive: MobilePairedMac?
|
||||
let existedBeforeUpsert: Bool
|
||||
if markActive, let account = stackUserID, !account.isEmpty {
|
||||
let existing = (try? await inner.loadAll(stackUserID: account, teamID: team)) ?? []
|
||||
previouslyActive = existing.first { $0.isActive }
|
||||
existedBeforeUpsert = existing.contains { $0.macDeviceID == macDeviceID }
|
||||
} else {
|
||||
previouslyActive = nil
|
||||
existedBeforeUpsert = true
|
||||
}
|
||||
try await inner.upsert(
|
||||
macDeviceID: macDeviceID,
|
||||
displayName: displayName,
|
||||
routes: routes,
|
||||
markActive: markActive,
|
||||
stackUserID: stackUserID,
|
||||
teamID: team,
|
||||
now: now
|
||||
)
|
||||
// Mirror to the DO only for a signed-in (account-scoped) host; anonymous
|
||||
// local pairings have no per-user collection to back up to. Routine route
|
||||
// and active-state uploads are intentionally non-authoritative for the
|
||||
// customization fields: a stale device must not erase a newer rename/color
|
||||
// selected on another device. Only `setCustomization` sends custom keys.
|
||||
guard let account = stackUserID, !account.isEmpty else { return }
|
||||
lastSignedInAccount = account
|
||||
let allowsTombstoneRevive = await clearPendingDelete(macDeviceID: macDeviceID, account: account, teamID: team)
|
||||
|| (markActive && !existedBeforeUpsert)
|
||||
await uploadCurrentRecord(
|
||||
macDeviceID: macDeviceID,
|
||||
account: account,
|
||||
teamID: team,
|
||||
includesCustomizations: false,
|
||||
allowTombstoneRevive: allowsTombstoneRevive
|
||||
)
|
||||
// `markActive` clears the active flag of the account's previously-active
|
||||
// host locally; mirror THAT one record too so the backup keeps its
|
||||
// single-active invariant — without re-uploading the whole account, which
|
||||
// would copy other-team hosts into the selected team's DO (the local rows
|
||||
// carry no team id to filter by). See `setActive`.
|
||||
if markActive, let previouslyActive, previouslyActive.macDeviceID != macDeviceID {
|
||||
await uploadCurrentRecord(
|
||||
macDeviceID: previouslyActive.macDeviceID,
|
||||
account: account,
|
||||
teamID: team,
|
||||
includesCustomizations: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist local customizations, then mirror the complete record to backup.
|
||||
public func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
let team = await teamIDProvider()
|
||||
let account = try? await accountForMac(macDeviceID, teamID: team)
|
||||
try await setCustomization(
|
||||
macDeviceID: macDeviceID,
|
||||
customName: customName,
|
||||
customColor: customColor,
|
||||
customIcon: customIcon,
|
||||
stackUserID: account,
|
||||
teamID: team,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
/// Load paired Macs after ensuring the signed-in account/team backup was restored.
|
||||
public func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac] {
|
||||
await restoreIfNeeded(stackUserID)
|
||||
// Scope to the current team (callers pass nil via the convenience overload),
|
||||
// so a multi-team user only sees the active team's Macs. NULL-team legacy
|
||||
// rows remain visible (the store's `team_id IS ? OR team_id IS NULL` rule).
|
||||
let team = await resolvedTeam(teamID)
|
||||
return try await inner.loadAll(stackUserID: stackUserID, teamID: team)
|
||||
}
|
||||
|
||||
/// Load the active Mac after ensuring the signed-in account/team backup was restored.
|
||||
public func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? {
|
||||
await restoreIfNeeded(stackUserID)
|
||||
let team = await resolvedTeam(teamID)
|
||||
return try await inner.activeMac(stackUserID: stackUserID, teamID: team)
|
||||
}
|
||||
|
||||
/// Mark one paired Mac active and mirror the changed active flags to backup.
|
||||
public func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {
|
||||
// Resolve the scope and the previously-active host BEFORE the flip, so we can
|
||||
// mirror exactly the two records that change. Scoped to the current team
|
||||
// (single-active is per (account, team)).
|
||||
let team = await resolvedTeam(teamID)
|
||||
let account: String?
|
||||
if let stackUserID {
|
||||
account = stackUserID
|
||||
} else {
|
||||
account = try? await accountForMac(macDeviceID, teamID: team)
|
||||
}
|
||||
let previouslyActive = (account != nil)
|
||||
? try? await inner.activeMac(stackUserID: account, teamID: team) : nil
|
||||
try await inner.setActive(macDeviceID: macDeviceID, stackUserID: account, teamID: team)
|
||||
// setActive flips the active flag for one host (and clears the previously-
|
||||
// active one in its scope) without going through `upsert`. Mirror ONLY those
|
||||
// two changed records to the DO so a "select host but don't connect, then
|
||||
// reinstall" sequence restores the right active host — WITHOUT a whole-
|
||||
// account upload, which would copy other-team hosts into the selected team's
|
||||
// DO (local rows carry no team id to filter by).
|
||||
guard let account else { return }
|
||||
lastSignedInAccount = account
|
||||
await uploadCurrentRecord(
|
||||
macDeviceID: macDeviceID,
|
||||
account: account,
|
||||
teamID: team,
|
||||
includesCustomizations: false
|
||||
)
|
||||
if let previouslyActive, previouslyActive.macDeviceID != macDeviceID {
|
||||
await uploadCurrentRecord(
|
||||
macDeviceID: previouslyActive.macDeviceID,
|
||||
account: account,
|
||||
teamID: team,
|
||||
includesCustomizations: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the active paired Mac locally and mirror the changed row to backup.
|
||||
public func clearActive(stackUserID: String?, teamID: String?) async throws {
|
||||
let team = await resolvedTeam(teamID)
|
||||
let previous = stackUserID != nil
|
||||
? try? await inner.activeMac(stackUserID: stackUserID, teamID: team) : nil
|
||||
try await inner.clearActive(stackUserID: stackUserID, teamID: team)
|
||||
guard let stackUserID, let previous else { return }
|
||||
lastSignedInAccount = stackUserID
|
||||
await uploadCurrentRecord(
|
||||
macDeviceID: previous.macDeviceID,
|
||||
account: stackUserID,
|
||||
teamID: team,
|
||||
includesCustomizations: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Persist local customizations in one explicit owner scope, then mirror the
|
||||
/// complete scoped row to backup.
|
||||
public func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
let team = await resolvedTeam(teamID)
|
||||
try await inner.setCustomization(
|
||||
macDeviceID: macDeviceID,
|
||||
customName: customName,
|
||||
customColor: customColor,
|
||||
customIcon: customIcon,
|
||||
stackUserID: stackUserID,
|
||||
teamID: team,
|
||||
now: now
|
||||
)
|
||||
let account: String?
|
||||
if let stackUserID {
|
||||
account = stackUserID
|
||||
} else {
|
||||
account = try? await accountForMac(macDeviceID, teamID: team)
|
||||
}
|
||||
guard let account else { return }
|
||||
lastSignedInAccount = account
|
||||
await uploadCurrentRecord(
|
||||
macDeviceID: macDeviceID,
|
||||
account: account,
|
||||
teamID: team,
|
||||
includesCustomizations: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove one paired Mac locally and tombstone it in backup when signed in.
|
||||
public func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {
|
||||
let team = await resolvedTeam(teamID)
|
||||
let account: String?
|
||||
if let stackUserID {
|
||||
account = stackUserID
|
||||
} else {
|
||||
account = try? await accountForMac(macDeviceID, teamID: team)
|
||||
}
|
||||
// Only mirror the delete while signed in; an anonymous removal has no
|
||||
// per-user backup to delete and would just fail auth and log noise.
|
||||
let backupAccount = account ?? lastSignedInAccount
|
||||
let scope = backupAccount.map { "\($0)\u{0}\(team ?? "")" }
|
||||
if let scope {
|
||||
// Persist the delete intent before removing the only local row. If the
|
||||
// app dies or the network upload fails after the local delete, the next
|
||||
// read/restore still applies this tombstone and retries the backup
|
||||
// delete instead of restoring the stale live record from the server.
|
||||
// The catch below rolls this intent back if the local delete itself
|
||||
// fails, so the outbox never claims a row was forgotten locally when it
|
||||
// was not.
|
||||
await addPendingDelete(macDeviceID: macDeviceID, scope: scope)
|
||||
}
|
||||
let draining = cancelInFlightRestoresReturningTasks()
|
||||
for task in draining { _ = await task.value }
|
||||
do {
|
||||
try await inner.remove(macDeviceID: macDeviceID, stackUserID: account, teamID: team)
|
||||
if let scope, let backupAccount {
|
||||
await flushPendingDeletes(scope: scope, account: backupAccount, teamID: team)
|
||||
}
|
||||
} catch {
|
||||
if let scope {
|
||||
await clearPendingDelete(macDeviceID: macDeviceID, scope: scope)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear local paired Macs without deleting the user's server backup.
|
||||
public func removeAll() async throws {
|
||||
// Sign-out wipe: clear local only. The server backup is intentionally
|
||||
// kept so the next sign-in restores the account's saved hosts.
|
||||
//
|
||||
// Cancel AND DRAIN any in-flight restore BEFORE wiping. A restore can pass
|
||||
// its `Task.isCancelled` check and then suspend inside `inner.upsert`;
|
||||
// cancellation does not withdraw that already-queued write. If we wiped
|
||||
// first, that upsert could land AFTER the wipe and resurrect the previous
|
||||
// account's Macs in the just-emptied store (the sign-out privacy boundary).
|
||||
// Awaiting the cancelled tasks guarantees every pending write has completed,
|
||||
// so the subsequent wipe is final.
|
||||
let draining = cancelInFlightRestoresReturningTasks()
|
||||
for task in draining { _ = await task.value }
|
||||
try await inner.removeAll()
|
||||
restoredScopes.removeAll()
|
||||
lastSignedInAccount = nil
|
||||
}
|
||||
|
||||
/// Cancel in-flight restore work so a sign-out/account switch cannot resume stale writes.
|
||||
public func cancelInFlightRestores() async {
|
||||
_ = cancelInFlightRestoresReturningTasks()
|
||||
}
|
||||
|
||||
/// Invalidate in-flight restores and return their handles so the caller can
|
||||
/// optionally DRAIN them (await completion) before relying on store state.
|
||||
/// Bumps the reset generation so any restore suspended at `await task.value`
|
||||
/// bails before memoizing, and cancels the tasks so `PairedMacRestore.run`'s
|
||||
/// `Task.isCancelled` checks fire. Does not touch `inner` — sign-out keeps the
|
||||
/// per-user rows; only `removeAll` wipes them, after draining.
|
||||
private func cancelInFlightRestoresReturningTasks() -> [Task<RestoreOutcome, Never>] {
|
||||
restoreBoundary.invalidate()
|
||||
resetGeneration &+= 1
|
||||
restoredScopes.removeAll()
|
||||
let tasks = Array(inFlight.values)
|
||||
inFlight.removeAll()
|
||||
for task in tasks { task.cancel() }
|
||||
return tasks
|
||||
}
|
||||
|
||||
/// Force a backup re-fetch + LWW merge for the signed-in scope, ignoring the
|
||||
/// once-per-launch memo. Used before multi-Mac aggregation so a secondary
|
||||
/// Mac that relaunched on a new port has its route refreshed locally before
|
||||
/// the read-only workspace fetch dials it. Best-effort; failures leave the
|
||||
/// local store untouched (``PairedMacRestore`` no-ops on a failed fetch).
|
||||
public func refreshFromBackup(stackUserID: String?) async {
|
||||
guard let account = stackUserID, !account.isEmpty else { return }
|
||||
lastSignedInAccount = account
|
||||
// Coalesce with any in-flight restore for this scope so we never run two
|
||||
// merges concurrently against the same store.
|
||||
let team = (await teamIDProvider()) ?? ""
|
||||
let scope = "\(account)\u{0}\(team.isEmpty ? "" : team)"
|
||||
let restoreTeam = team.isEmpty ? nil : team
|
||||
await applyPendingLocalDeletes(scope: scope, account: account, teamID: restoreTeam)
|
||||
_ = await flushPendingDeletes(scope: scope, account: account, teamID: restoreTeam)
|
||||
let task: Task<RestoreOutcome, Never>
|
||||
if let existing = inFlight[scope] {
|
||||
task = existing
|
||||
} else {
|
||||
let restore = PairedMacRestore(store: inner, backup: backup)
|
||||
let pendingDeletes = await pendingDeleteIDs(scope: scope)
|
||||
let boundaryGeneration = restoreBoundary.generation
|
||||
let created = Task {
|
||||
await restore.run(
|
||||
accountID: account,
|
||||
teamID: restoreTeam,
|
||||
boundary: restoreBoundary,
|
||||
boundaryGeneration: boundaryGeneration,
|
||||
locallyDeletedMacDeviceIDs: pendingDeletes
|
||||
)
|
||||
}
|
||||
inFlight[scope] = created
|
||||
task = created
|
||||
}
|
||||
let generation = resetGeneration
|
||||
let outcome = await task.value
|
||||
// A sign-out wipe across the await already cleared inFlight/restoredScopes;
|
||||
// do not re-touch them (clobbering a post-wipe inFlight entry, or memoizing
|
||||
// a scope the wipe removed and suppressing a same-launch re-sign-in restore).
|
||||
guard resetGeneration == generation else { return }
|
||||
inFlight[scope] = nil
|
||||
if outcome.completed {
|
||||
restoredScopes.insert(scope)
|
||||
await flushPendingDeletes(scope: scope, account: account, teamID: restoreTeam)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
/// The team to scope an inner call to: an explicit `teamID` wins (e.g. a restore
|
||||
/// that knows its team), else the currently-selected team. (`??` can't take an
|
||||
/// async right-hand side, so this is a plain method.)
|
||||
private func resolvedTeam(_ teamID: String?) async -> String? {
|
||||
if let teamID { return teamID }
|
||||
return await teamIDProvider()
|
||||
}
|
||||
|
||||
/// Resolve the owning Stack account of a paired Mac, or nil if unknown. Reads
|
||||
/// across ALL teams (find-by-id) so a Mac is resolvable regardless of which team
|
||||
/// is selected.
|
||||
private func accountForMac(_ macDeviceID: String, teamID: String?) async throws -> String? {
|
||||
let all = try await inner.loadAll(stackUserID: nil, teamID: teamID)
|
||||
return all.first { $0.macDeviceID == macDeviceID }?.stackUserID
|
||||
}
|
||||
|
||||
/// Build a backup record for a Mac from the local row. Callers choose whether
|
||||
/// that record is encoded with authoritative customization keys; routine
|
||||
/// route/active refreshes omit them so the worker preserves newer server state.
|
||||
/// Timestamps are ms since epoch (the backup wire format).
|
||||
static func backupRecord(from mac: MobilePairedMac) -> PairedMacBackupRecord {
|
||||
PairedMacBackupRecord(
|
||||
macDeviceID: mac.macDeviceID,
|
||||
displayName: mac.displayName,
|
||||
routes: mac.routes,
|
||||
createdAt: mac.createdAt.timeIntervalSince1970 * 1000.0,
|
||||
lastSeenAt: mac.lastSeenAt.timeIntervalSince1970 * 1000.0,
|
||||
isActive: mac.isActive,
|
||||
customName: mac.customName,
|
||||
customColor: mac.customColor,
|
||||
customIcon: mac.customIcon
|
||||
)
|
||||
}
|
||||
|
||||
/// Upload the current record for one Mac. `includesCustomizations` is true
|
||||
/// only for explicit rename/color/icon writes; other mirrors preserve the
|
||||
/// server's current customizations. Best-effort.
|
||||
@discardableResult
|
||||
private func uploadCurrentRecord(
|
||||
macDeviceID: String,
|
||||
account: String,
|
||||
teamID: String? = nil,
|
||||
includesCustomizations: Bool = false,
|
||||
allowTombstoneRevive: Bool = false
|
||||
) async -> Bool {
|
||||
let team = await resolvedTeam(teamID)
|
||||
guard let mac = (try? await inner.loadAll(stackUserID: account, teamID: team))?
|
||||
.first(where: { $0.macDeviceID == macDeviceID }) else { return false }
|
||||
let record = Self.backupRecord(from: mac)
|
||||
let op: PairedMacBackupOp
|
||||
if allowTombstoneRevive {
|
||||
op = includesCustomizations
|
||||
? .revive(record)
|
||||
: .revivePreservingCustomizations(record)
|
||||
} else if includesCustomizations {
|
||||
op = .upsert(record)
|
||||
} else {
|
||||
op = .upsertPreservingCustomizations(record)
|
||||
}
|
||||
return await backup.upload(ops: [op], teamID: team, expectedUserID: account)
|
||||
}
|
||||
|
||||
/// Run the backup restore once per signed-in (account, team) scope this
|
||||
/// launch. Concurrent reads share one in-flight restore; only a SUCCESSFUL
|
||||
/// fetch is memoized, so a transient failure retries on the next read.
|
||||
private func restoreIfNeeded(_ stackUserID: String?) async {
|
||||
guard let account = stackUserID, !account.isEmpty else { return }
|
||||
lastSignedInAccount = account
|
||||
let team = (await teamIDProvider()) ?? ""
|
||||
let scope = "\(account)\u{0}\(team.isEmpty ? "" : team)"
|
||||
let restoreTeam = team.isEmpty ? nil : team
|
||||
await applyPendingLocalDeletes(scope: scope, account: account, teamID: restoreTeam)
|
||||
_ = await flushPendingDeletes(scope: scope, account: account, teamID: restoreTeam)
|
||||
if restoredScopes.contains(scope) { return }
|
||||
|
||||
let task: Task<RestoreOutcome, Never>
|
||||
if let existing = inFlight[scope] {
|
||||
task = existing
|
||||
} else {
|
||||
let restore = PairedMacRestore(store: inner, backup: backup)
|
||||
let pendingDeletes = await pendingDeleteIDs(scope: scope)
|
||||
let boundaryGeneration = restoreBoundary.generation
|
||||
let created = Task {
|
||||
await restore.run(
|
||||
accountID: account,
|
||||
teamID: restoreTeam,
|
||||
boundary: restoreBoundary,
|
||||
boundaryGeneration: boundaryGeneration,
|
||||
locallyDeletedMacDeviceIDs: pendingDeletes
|
||||
)
|
||||
}
|
||||
inFlight[scope] = created
|
||||
task = created
|
||||
}
|
||||
let generation = resetGeneration
|
||||
let outcome = await task.value
|
||||
// A sign-out wipe across the await already cleared inFlight/restoredScopes;
|
||||
// do not re-touch them (we'd clobber a post-wipe inFlight entry or memoize a
|
||||
// scope the wipe removed, suppressing a same-launch re-sign-in restore).
|
||||
guard resetGeneration == generation else { return }
|
||||
inFlight[scope] = nil
|
||||
if outcome.completed {
|
||||
restoredScopes.insert(scope)
|
||||
await flushPendingDeletes(scope: scope, account: account, teamID: restoreTeam)
|
||||
}
|
||||
}
|
||||
|
||||
private func pendingDeleteIDs(scope: String) async -> Set<String> {
|
||||
if let ids = pendingDeleteIDsByScope[scope] { return ids }
|
||||
let ids = await pendingDeleteStore.load(scope: scope)
|
||||
pendingDeleteIDsByScope[scope] = ids
|
||||
return ids
|
||||
}
|
||||
|
||||
private func savePendingDeleteIDs(_ ids: Set<String>, scope: String) async {
|
||||
pendingDeleteIDsByScope[scope] = ids
|
||||
await pendingDeleteStore.save(ids, scope: scope)
|
||||
}
|
||||
|
||||
private func addPendingDelete(macDeviceID: String, scope: String) async {
|
||||
let trimmed = macDeviceID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
var ids = await pendingDeleteIDs(scope: scope)
|
||||
ids.insert(trimmed)
|
||||
await savePendingDeleteIDs(ids, scope: scope)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func clearPendingDelete(macDeviceID: String, account: String, teamID: String?) async -> Bool {
|
||||
let scope = "\(account)\u{0}\(teamID ?? "")"
|
||||
return await clearPendingDelete(macDeviceID: macDeviceID, scope: scope)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func clearPendingDelete(macDeviceID: String, scope: String) async -> Bool {
|
||||
var ids = await pendingDeleteIDs(scope: scope)
|
||||
guard ids.remove(macDeviceID) != nil else { return false }
|
||||
await savePendingDeleteIDs(ids, scope: scope)
|
||||
return true
|
||||
}
|
||||
|
||||
private func applyPendingLocalDeletes(scope: String, account: String, teamID: String?) async {
|
||||
let ids = await pendingDeleteIDs(scope: scope)
|
||||
guard !ids.isEmpty else { return }
|
||||
for macDeviceID in ids {
|
||||
try? await inner.remove(macDeviceID: macDeviceID, stackUserID: account, teamID: teamID)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func flushPendingDeletes(scope: String, account: String, teamID: String?) async -> Set<String> {
|
||||
let ids = await pendingDeleteIDs(scope: scope)
|
||||
guard !ids.isEmpty else { return ids }
|
||||
let ops = ids.sorted().map { PairedMacBackupOp.delete(macDeviceID: $0) }
|
||||
guard await backup.upload(ops: ops, teamID: teamID, expectedUserID: account) else { return ids }
|
||||
await savePendingDeleteIDs([], scope: scope)
|
||||
return []
|
||||
}
|
||||
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/// In-memory pending-delete store for tests and previews.
|
||||
public actor InMemoryPairedMacPendingDeleteStore: PairedMacPendingDeleteStoring {
|
||||
private var idsByScope: [String: Set<String>] = [:]
|
||||
|
||||
/// Create an empty in-memory pending-delete store.
|
||||
public init() {}
|
||||
|
||||
/// Load pending tombstones for one account/team scope.
|
||||
public func load(scope: String) async -> Set<String> {
|
||||
idsByScope[scope] ?? []
|
||||
}
|
||||
|
||||
/// Replace pending tombstones for one account/team scope.
|
||||
public func save(_ ids: Set<String>, scope: String) async {
|
||||
if ids.isEmpty {
|
||||
idsByScope.removeValue(forKey: scope)
|
||||
} else {
|
||||
idsByScope[scope] = ids
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all pending tombstones.
|
||||
public func removeAll() async {
|
||||
idsByScope.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
/// Derives a short, user-facing build-channel label for a Mac from what its
|
||||
/// presence heartbeat reports — its bundle id and dev tag — so the Computers
|
||||
/// screen can show whether a host is a DEV build (and which tag), Nightly, RC,
|
||||
/// Staging, or Stable.
|
||||
///
|
||||
/// The dev tag is the primary DEV signal: a tagged `reload.sh` build sets
|
||||
/// `CMUX_TAG`, so any non-`"default"` tag means a DEV build and the tag is the
|
||||
/// thing worth showing. Otherwise the channel comes from the bundle-id suffix.
|
||||
///
|
||||
public struct MacBuildChannel: Sendable {
|
||||
/// Create a build-channel labeler.
|
||||
public init() {}
|
||||
|
||||
/// A label like `"DEV · my-tag"`, `"Nightly"`, `"RC"`, `"Staging"`, or
|
||||
/// `"Stable"`, or `nil` when there is nothing identifiable to show (an older
|
||||
/// host that reports neither a meaningful tag nor a known bundle id).
|
||||
public func label(bundleID: String?, tag: String?) -> String? {
|
||||
let trimmedTag = tag?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let devTag = (trimmedTag?.isEmpty == false && trimmedTag != "default") ? trimmedTag : nil
|
||||
if let devTag {
|
||||
return "DEV · \(devTag)"
|
||||
}
|
||||
|
||||
// The channel is the component RIGHT AFTER the base bundle id; a tagged
|
||||
// build appends a further `.slug` (e.g. `com.cmuxterm.app.nightly.my-tag`,
|
||||
// `com.cmuxterm.app.rc`), so match the component, not the suffix. Mirrors
|
||||
// the canonical `SocketPathMarkerFiles.variant` on macOS — kept in sync as
|
||||
// channels are added (Stable/Nightly/Staging/RC). RC may not exist yet (a
|
||||
// future release-candidate desktop build), but is handled ahead of time.
|
||||
let bundle = (bundleID ?? "").lowercased()
|
||||
let base = "com.cmuxterm.app"
|
||||
if bundle == base { return "Stable" }
|
||||
if bundle.hasPrefix(base + ".") {
|
||||
let rest = bundle.dropFirst(base.count + 1)
|
||||
let channel = rest.split(separator: ".", maxSplits: 1).first.map(String.init) ?? ""
|
||||
switch channel {
|
||||
case "nightly": return "Nightly"
|
||||
case "rc": return "RC"
|
||||
case "staging": return "Staging"
|
||||
case "debug", "dev": return "DEV"
|
||||
default: return nil // unknown channel component — don't guess
|
||||
}
|
||||
}
|
||||
// A non-`com.cmuxterm.app` bundle that is clearly a dev build (e.g. the iOS
|
||||
// dev bundle `dev.cmux.*`).
|
||||
if bundle.hasPrefix("dev.cmux") { return "DEV" }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobileRPC
|
||||
import Foundation
|
||||
|
||||
/// One paired Mac's live connection in the multi-Mac connection pool (P2).
|
||||
///
|
||||
/// The composite holds one entry per connected Mac, keyed by `macDeviceID`.
|
||||
/// Today the pool tracks the single foreground connection that drives terminal
|
||||
/// I/O and the connected UI; P3 adds read-only connections to the user's other
|
||||
/// Macs so their workspaces can be fetched and merged into one list. Keeping
|
||||
/// each connection's `generation` lets a per-Mac connection be invalidated
|
||||
/// independently of the others, instead of the single global generation that
|
||||
/// cancels everything on any attach.
|
||||
struct MacConnection {
|
||||
/// The stable device id of the Mac this connection targets.
|
||||
let macDeviceID: String
|
||||
/// The attach ticket the connection was established with.
|
||||
let ticket: CmxAttachTicket
|
||||
/// The route (host/port + kind) the client dialed.
|
||||
let route: CmxAttachRoute
|
||||
/// The live RPC client for this Mac.
|
||||
let client: MobileCoreRPCClient
|
||||
/// The connection-attempt generation that established this client.
|
||||
let generation: UUID
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
public import Foundation
|
||||
|
||||
/// The `mobilePairedMacBackup` feature flag. Same DEBUG-on/Release-off seam as
|
||||
/// ``MobileDeviceListLocalFirst`` / ``PresenceClient/resolvedServiceBaseURL``:
|
||||
/// an env override wins (dogfood/tagged builds), then a UserDefaults override,
|
||||
/// then DEBUG → on / Release → off.
|
||||
///
|
||||
/// When enabled, the iOS app mirrors its local paired-Mac store to the per-team
|
||||
/// Durable Object (scoped to the signed-in user) and restores it on sign-in, so
|
||||
/// saved hosts and their IPs — including manually typed ones — survive an app
|
||||
/// upgrade, a bundle-id change, or a reinstall. Off in Release until dogfood
|
||||
/// approves flipping it, so production users are unaffected.
|
||||
public struct MobilePairedMacBackup: Sendable, Equatable {
|
||||
/// Environment variable override for dogfood and tagged builds.
|
||||
public static let envKey = "CMUX_MOBILE_PAIRED_MAC_BACKUP"
|
||||
/// UserDefaults key for local dogfood toggles.
|
||||
public static let defaultsKey = "mobilePairedMacBackup"
|
||||
|
||||
/// Whether paired-Mac backup/restore is enabled for this process.
|
||||
public let isEnabled: Bool
|
||||
|
||||
/// Create a resolved paired-Mac backup flag value.
|
||||
public init(isEnabled: Bool) {
|
||||
self.isEnabled = isEnabled
|
||||
}
|
||||
|
||||
/// Resolve the flag from the environment override, then a UserDefaults
|
||||
/// override, then the build flavor (DEBUG on / Release off).
|
||||
public static func resolved(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
defaults: UserDefaults = .standard,
|
||||
isDebugBuild: Bool = MobilePairedMacBackup.isDebugBuild
|
||||
) -> MobilePairedMacBackup {
|
||||
func parseBool(_ raw: String) -> Bool {
|
||||
switch raw.lowercased() {
|
||||
case "1", "true", "yes", "on": return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
if let raw = environment[envKey]?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty {
|
||||
return MobilePairedMacBackup(isEnabled: parseBool(raw))
|
||||
}
|
||||
if defaults.object(forKey: defaultsKey) != nil {
|
||||
return MobilePairedMacBackup(isEnabled: defaults.bool(forKey: defaultsKey))
|
||||
}
|
||||
return MobilePairedMacBackup(isEnabled: isDebugBuild)
|
||||
}
|
||||
|
||||
/// Compile-time build flavor, parameterized above for testability.
|
||||
public static var isDebugBuild: Bool {
|
||||
#if DEBUG
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -34,12 +34,56 @@ extension CMUXMobileShellStore {
|
||||
return deeplinkWorkspaceNavigationRequest?.workspaceID
|
||||
}
|
||||
|
||||
/// The current UI row id for a Mac-local workspace id, if that workspace is
|
||||
/// loaded. Push payloads carry Mac-local ids; the aggregated list may scope
|
||||
/// row ids by Mac for SwiftUI identity.
|
||||
public func workspaceID(matchingRemoteWorkspaceID remoteWorkspaceID: String) -> MobileWorkspacePreview.ID? {
|
||||
workspaceID(matchingRemoteWorkspaceID: remoteWorkspaceID, macDeviceID: nil)
|
||||
}
|
||||
|
||||
/// The current UI row id for a Mac-local workspace id owned by a specific
|
||||
/// Mac. New push payloads carry the Mac's device id so duplicate local
|
||||
/// workspace ids across paired Macs do not resolve to the first visible row.
|
||||
public func workspaceID(
|
||||
matchingRemoteWorkspaceID remoteWorkspaceID: String,
|
||||
macDeviceID: String?
|
||||
) -> MobileWorkspacePreview.ID? {
|
||||
rowWorkspaceID(
|
||||
forRemoteWorkspaceID: MobileWorkspacePreview.ID(rawValue: remoteWorkspaceID),
|
||||
macDeviceID: macDeviceID
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the visible selection matches a Mac-local workspace id.
|
||||
public func selectedWorkspaceMatches(remoteWorkspaceID: String) -> Bool {
|
||||
selectedWorkspaceMatches(remoteWorkspaceID: remoteWorkspaceID, macDeviceID: nil)
|
||||
}
|
||||
|
||||
/// Whether the visible selection matches a Mac-local workspace id owned by
|
||||
/// a specific Mac.
|
||||
public func selectedWorkspaceMatches(remoteWorkspaceID: String, macDeviceID: String?) -> Bool {
|
||||
guard let selectedWorkspaceID,
|
||||
let selectedWorkspace = workspaces.first(where: { $0.id == selectedWorkspaceID }),
|
||||
selectedWorkspace.rpcWorkspaceID.rawValue == remoteWorkspaceID else {
|
||||
return false
|
||||
}
|
||||
guard let macDeviceID, !macDeviceID.isEmpty else { return true }
|
||||
return selectedWorkspace.macDeviceID == macDeviceID
|
||||
}
|
||||
|
||||
/// The workspace whose terminal list contains `surfaceID`, if any. Used by
|
||||
/// the push coordinator to resolve surface-only notification deep links to
|
||||
/// a navigable workspace, and to keep a tap parked until the terminal's
|
||||
/// snapshot has arrived.
|
||||
public func workspaceID(containingSurfaceID surfaceID: String) -> MobileWorkspacePreview.ID? {
|
||||
workspaceID(forTerminalID: surfaceID)
|
||||
workspaceID(containingSurfaceID: surfaceID, macDeviceID: nil)
|
||||
}
|
||||
|
||||
/// The workspace owned by `macDeviceID` whose terminal list contains
|
||||
/// `surfaceID`, if any. Legacy payloads without a Mac id keep the historical
|
||||
/// first-match behavior.
|
||||
public func workspaceID(containingSurfaceID surfaceID: String, macDeviceID: String?) -> MobileWorkspacePreview.ID? {
|
||||
workspaceID(forTerminalID: surfaceID, macDeviceID: macDeviceID)
|
||||
}
|
||||
|
||||
/// Whether `surfaceID` is a terminal of the workspace `workspaceID`.
|
||||
@@ -52,7 +96,16 @@ extension CMUXMobileShellStore {
|
||||
|
||||
/// The workspace whose terminal list contains `terminalID`, if any.
|
||||
func workspaceID(forTerminalID terminalID: String) -> MobileWorkspacePreview.ID? {
|
||||
workspaceID(forTerminalID: terminalID, macDeviceID: nil)
|
||||
}
|
||||
|
||||
/// The workspace owned by `macDeviceID` whose terminal list contains
|
||||
/// `terminalID`, if any.
|
||||
func workspaceID(forTerminalID terminalID: String, macDeviceID: String?) -> MobileWorkspacePreview.ID? {
|
||||
for workspace in workspaces {
|
||||
if let macDeviceID, !macDeviceID.isEmpty, workspace.macDeviceID != macDeviceID {
|
||||
continue
|
||||
}
|
||||
if workspace.terminals.contains(where: { $0.id.rawValue == terminalID }) {
|
||||
return workspace.id
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
internal import CmuxMobileSupport
|
||||
import Foundation
|
||||
|
||||
extension MobileShellComposite {
|
||||
/// Build the host app version string shown in pairing/status diagnostics.
|
||||
static func mobileShellVersionDisplay(
|
||||
version: String?,
|
||||
build: String?,
|
||||
compatibilityVersion: Int?
|
||||
) -> String {
|
||||
let version = version ?? mobileShellCompatibilityDisplay(compatibilityVersion)
|
||||
guard let build = mobileShellNormalizedNonEmpty(build) else { return version }
|
||||
return "\(version) (\(build))"
|
||||
}
|
||||
|
||||
/// Return a localized compatibility fallback for Macs that do not report an app version.
|
||||
static func mobileShellCompatibilityDisplay(_ compatibilityVersion: Int?) -> String {
|
||||
guard let compatibilityVersion, compatibilityVersion > 0 else {
|
||||
return L10n.string(
|
||||
"mobile.pairing.compatibilityUnknown",
|
||||
defaultValue: "unknown compatibility"
|
||||
)
|
||||
}
|
||||
return String(
|
||||
format: L10n.string(
|
||||
"mobile.pairing.compatibilityDisplayFormat",
|
||||
defaultValue: "compatibility %@"
|
||||
),
|
||||
"\(compatibilityVersion)"
|
||||
)
|
||||
}
|
||||
|
||||
/// Normalize optional email values before comparing host-reported identity.
|
||||
static func mobileShellNormalizedEmail(_ value: String?) -> String? {
|
||||
mobileShellNormalizedNonEmpty(value)?.lowercased()
|
||||
}
|
||||
|
||||
/// Trim optional strings and collapse blanks to nil.
|
||||
static func mobileShellNormalizedNonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed?.isEmpty == false ? trimmed : nil
|
||||
}
|
||||
}
|
||||
+51
-13
@@ -9,37 +9,75 @@ private let mobileShellLog = Logger(
|
||||
)
|
||||
|
||||
extension MobileShellComposite {
|
||||
/// Enqueue and send phone-side notification dismissals to the connected Mac.
|
||||
/// Enqueue and send phone-side notification dismissals to the owning Mac.
|
||||
///
|
||||
/// IDs are stable Mac notification identifiers from `cmux.notificationId`.
|
||||
/// They are stored before the RPC and removed only after the Mac confirms,
|
||||
/// so a dropped connection flushes them on the next successful subscribe.
|
||||
public func dismissNotification(ids: [String]) async {
|
||||
let trimmed = ids
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
public func dismissNotification(ids: [String], macDeviceID: String? = nil) async {
|
||||
let mac = macDeviceID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await dismissNotifications(
|
||||
ids.map { (id: $0, macDeviceID: mac?.isEmpty == false ? mac : nil) },
|
||||
enqueueFirst: true
|
||||
)
|
||||
}
|
||||
|
||||
private func dismissNotifications(
|
||||
_ dismisses: [(id: String, macDeviceID: String?)],
|
||||
enqueueFirst: Bool
|
||||
) async {
|
||||
let trimmed = dismisses.compactMap { dismiss -> (id: String, macDeviceID: String?)? in
|
||||
let id = dismiss.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !id.isEmpty else { return nil }
|
||||
let mac = dismiss.macDeviceID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (id: id, macDeviceID: mac?.isEmpty == false ? mac : nil)
|
||||
}
|
||||
guard !trimmed.isEmpty else { return }
|
||||
pendingDismissQueue.enqueue(trimmed)
|
||||
guard let client = remoteClient else { return }
|
||||
if enqueueFirst {
|
||||
pendingDismissQueue.enqueue(trimmed)
|
||||
}
|
||||
let groups = Dictionary(grouping: trimmed, by: \.macDeviceID)
|
||||
for (macDeviceID, dismisses) in groups {
|
||||
await sendNotificationDismisses(dismisses, macDeviceID: macDeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendNotificationDismisses(
|
||||
_ dismisses: [(id: String, macDeviceID: String?)],
|
||||
macDeviceID: String?
|
||||
) async {
|
||||
let ids = dismisses.map(\.id)
|
||||
guard let client = notificationDismissClient(for: macDeviceID) else { return }
|
||||
do {
|
||||
let request = try MobileCoreRPCClient.requestData(
|
||||
method: "notification.dismiss",
|
||||
params: [
|
||||
"notification_ids": trimmed,
|
||||
"notification_ids": ids,
|
||||
"client_id": clientID,
|
||||
]
|
||||
)
|
||||
_ = try await client.sendRequest(request)
|
||||
pendingDismissQueue.remove(trimmed)
|
||||
pendingDismissQueue.remove(dismisses)
|
||||
} catch {
|
||||
mobileShellLog.error("notification dismiss sync failed count=\(trimmed.count, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
mobileShellLog.error("notification dismiss sync failed count=\(ids.count, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func flushPendingNotificationDismisses() async {
|
||||
let pending = pendingDismissQueue.pendingIDs
|
||||
private func notificationDismissClient(for macDeviceID: String?) -> MobileCoreRPCClient? {
|
||||
guard let macDeviceID, !macDeviceID.isEmpty else { return remoteClient }
|
||||
if foregroundMacDeviceID == macDeviceID {
|
||||
return remoteClient
|
||||
}
|
||||
return secondaryMacSubscriptions[macDeviceID]?.client
|
||||
}
|
||||
|
||||
func flushPendingNotificationDismisses(macDeviceID: String? = nil) async {
|
||||
let pending = pendingDismissQueue.pendingDismisses.filter { dismiss in
|
||||
guard let macDeviceID else { return true }
|
||||
return dismiss.macDeviceID == macDeviceID
|
||||
}
|
||||
guard !pending.isEmpty else { return }
|
||||
await dismissNotification(ids: pending)
|
||||
await dismissNotifications(pending, enqueueFirst: false)
|
||||
}
|
||||
|
||||
/// Clear delivered iOS banners for Mac notification identifiers.
|
||||
|
||||
+2
-1
@@ -68,8 +68,9 @@ extension MobileShellComposite {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let remoteWorkspaceID = remoteWorkspaceID(for: workspaceID)
|
||||
var params: [String: Any] = [
|
||||
"workspace_id": workspaceID.rawValue,
|
||||
"workspace_id": remoteWorkspaceID.rawValue,
|
||||
"surface_id": delivery.surfaceID,
|
||||
"client_id": clientID,
|
||||
"delta_lines": delivery.lines,
|
||||
|
||||
+31
-4
@@ -24,6 +24,7 @@ extension MobileShellComposite {
|
||||
/// - id: The workspace to rename.
|
||||
/// - title: The new title. Whitespace-only titles are ignored.
|
||||
public func renameWorkspace(id: MobileWorkspacePreview.ID, title: String) async {
|
||||
guard workspaceActionCapabilities(for: id).supportsWorkspaceActions else { return }
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
var params = workspaceMutationParams(id: id)
|
||||
@@ -46,6 +47,7 @@ extension MobileShellComposite {
|
||||
/// - id: The workspace to pin or unpin.
|
||||
/// - pinned: `true` to pin, `false` to unpin.
|
||||
public func setWorkspacePinned(id: MobileWorkspacePreview.ID, _ pinned: Bool) async {
|
||||
guard workspaceActionCapabilities(for: id).supportsWorkspaceActions else { return }
|
||||
var params = workspaceMutationParams(id: id)
|
||||
params["action"] = pinned ? "pin" : "unpin"
|
||||
await sendWorkspaceMutation(
|
||||
@@ -62,6 +64,7 @@ extension MobileShellComposite {
|
||||
/// - id: The workspace to mark.
|
||||
/// - unread: `true` to mark unread, `false` to mark read.
|
||||
public func setWorkspaceUnread(id: MobileWorkspacePreview.ID, _ unread: Bool) async {
|
||||
guard workspaceActionCapabilities(for: id).supportsReadStateActions else { return }
|
||||
var params = workspaceMutationParams(id: id)
|
||||
params["action"] = unread ? "mark_unread" : "mark_read"
|
||||
await sendWorkspaceMutation(
|
||||
@@ -79,6 +82,7 @@ extension MobileShellComposite {
|
||||
/// the last workspace, the refresh restores the row state on iOS.
|
||||
/// - Parameter id: The workspace to close.
|
||||
public func closeWorkspace(id: MobileWorkspacePreview.ID) async {
|
||||
guard workspaceActionCapabilities(for: id).supportsCloseActions else { return }
|
||||
await sendWorkspaceMutation(
|
||||
method: "workspace.close",
|
||||
params: workspaceMutationParams(id: id),
|
||||
@@ -87,13 +91,30 @@ extension MobileShellComposite {
|
||||
)
|
||||
}
|
||||
|
||||
private func workspaceActionCapabilities(for id: MobileWorkspacePreview.ID) -> MobileWorkspaceActionCapabilities {
|
||||
workspaces.first { $0.id == id }?.actionCapabilities ?? .none
|
||||
}
|
||||
|
||||
private func sendWorkspaceMutation(
|
||||
method: String,
|
||||
params: [String: Any],
|
||||
id: MobileWorkspacePreview.ID,
|
||||
actionName: String
|
||||
) async {
|
||||
guard let client = remoteClient else { return }
|
||||
// Route the mutation to the Mac that actually OWNS this workspace. The
|
||||
// aggregated list can include rows from secondary Macs, whose connection is
|
||||
// not `remoteClient`; sending every mutation to the foreground client would
|
||||
// silently hit the wrong Mac (fail, or — with a colliding workspace id —
|
||||
// mutate a foreground workspace). The foreground path is unchanged for
|
||||
// foreground-owned (or single-Mac / anonymous) rows.
|
||||
let target = workspaceMutationTarget(for: id)
|
||||
guard let client = target.client else {
|
||||
// Owner is a known non-foreground Mac with no live connection: can't
|
||||
// deliver. Snap the row back to the authoritative state instead of
|
||||
// misrouting to the foreground Mac.
|
||||
await refreshWorkspaces()
|
||||
return
|
||||
}
|
||||
do {
|
||||
let request = try MobileCoreRPCClient.requestData(
|
||||
method: method,
|
||||
@@ -102,15 +123,21 @@ extension MobileShellComposite {
|
||||
_ = try await client.sendRequest(request)
|
||||
} catch {
|
||||
guard !disconnectForAuthorizationFailureIfNeeded(error) else { return }
|
||||
markMacConnectionUnavailableIfNeeded(after: error)
|
||||
// Only the foreground connection's health drives the foreground
|
||||
// unavailable/reconnect UI; a failed write to a secondary Mac must not
|
||||
// tear the foreground session down.
|
||||
if target.isForeground {
|
||||
markMacConnectionUnavailableIfNeeded(after: error)
|
||||
}
|
||||
mobileShellLog.error("workspace mutation failed action=\(actionName, privacy: .public) id=\(id.rawValue, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||
}
|
||||
await refreshWorkspaces()
|
||||
// Re-sync the authoritative list for the Mac we actually mutated.
|
||||
await refreshAfterWorkspaceMutation(target)
|
||||
}
|
||||
|
||||
private func workspaceMutationParams(id: MobileWorkspacePreview.ID) -> [String: Any] {
|
||||
var params: [String: Any] = [
|
||||
"workspace_id": id.rawValue,
|
||||
"workspace_id": remoteWorkspaceID(for: id).rawValue,
|
||||
"client_id": clientID,
|
||||
]
|
||||
if let windowID = workspaces.first(where: { $0.id == id })?.windowID {
|
||||
|
||||
+1564
-267
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
/// Captured account/team owner for async scoped loads.
|
||||
///
|
||||
/// A load may suspend while the user switches teams; publishing is allowed only
|
||||
/// if this snapshot still matches when the await returns.
|
||||
struct MobileShellScopeSnapshot: Equatable, Sendable {
|
||||
let userID: String
|
||||
let teamID: String?
|
||||
let generation: Int
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/// The backup transport seam used by backup mirroring and restore.
|
||||
public protocol PairedMacBackingUp: Sendable {
|
||||
/// Push backup mutations best-effort.
|
||||
@discardableResult
|
||||
func upload(ops: [PairedMacBackupOp]) async -> Bool
|
||||
|
||||
/// Push backup mutations best-effort for an already-captured team scope.
|
||||
@discardableResult
|
||||
func upload(ops: [PairedMacBackupOp], teamID: String?) async -> Bool
|
||||
|
||||
/// Push backup mutations only if auth still belongs to the captured account.
|
||||
@discardableResult
|
||||
func upload(ops: [PairedMacBackupOp], teamID: String?, expectedUserID: String?) async -> Bool
|
||||
|
||||
/// Fetch the caller's full backed-up list, or `nil` on transport/auth failure.
|
||||
func fetchAll() async -> [PairedMacBackupRecord]?
|
||||
|
||||
/// Fetch the caller's full backed-up list for an already-captured team scope.
|
||||
func fetchAll(teamID: String?) async -> [PairedMacBackupRecord]?
|
||||
|
||||
/// Fetch live records plus retained delete tombstones, or `nil` on
|
||||
/// transport/auth failure.
|
||||
func fetchSnapshot() async -> PairedMacBackupSnapshot?
|
||||
|
||||
/// Fetch live records plus retained delete tombstones for an
|
||||
/// already-captured team scope.
|
||||
func fetchSnapshot(teamID: String?) async -> PairedMacBackupSnapshot?
|
||||
|
||||
/// Fetch live records and tombstones only if auth still belongs to the captured account.
|
||||
func fetchSnapshot(teamID: String?, expectedUserID: String?) async -> PairedMacBackupSnapshot?
|
||||
}
|
||||
|
||||
/// Convenience defaults for backup test doubles and simple implementations.
|
||||
public extension PairedMacBackingUp {
|
||||
/// Default explicit-scope upload for test doubles that do not care about team routing.
|
||||
@discardableResult
|
||||
func upload(ops: [PairedMacBackupOp], teamID: String?) async -> Bool {
|
||||
await upload(ops: ops)
|
||||
}
|
||||
|
||||
/// Default expected-account upload for test doubles that do not model auth.
|
||||
@discardableResult
|
||||
func upload(ops: [PairedMacBackupOp], teamID: String?, expectedUserID: String?) async -> Bool {
|
||||
await upload(ops: ops, teamID: teamID)
|
||||
}
|
||||
|
||||
/// Default explicit-scope fetch for test doubles that do not care about team routing.
|
||||
func fetchAll(teamID: String?) async -> [PairedMacBackupRecord]? {
|
||||
await fetchAll()
|
||||
}
|
||||
|
||||
/// Default snapshot fetch for test doubles/simple implementations that only
|
||||
/// model live records.
|
||||
func fetchSnapshot() async -> PairedMacBackupSnapshot? {
|
||||
guard let records = await fetchAll() else { return nil }
|
||||
return PairedMacBackupSnapshot(records: records, deletedMacDeviceIDs: [])
|
||||
}
|
||||
|
||||
/// Default explicit-scope snapshot fetch.
|
||||
func fetchSnapshot(teamID: String?) async -> PairedMacBackupSnapshot? {
|
||||
await fetchSnapshot()
|
||||
}
|
||||
|
||||
/// Default expected-account snapshot fetch for test doubles that do not model auth.
|
||||
func fetchSnapshot(teamID: String?, expectedUserID: String?) async -> PairedMacBackupSnapshot? {
|
||||
await fetchSnapshot(teamID: teamID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
public import Foundation
|
||||
import os
|
||||
|
||||
private let pairedMacBackupLog = Logger(subsystem: "com.cmuxterm.app", category: "PairedMacBackup")
|
||||
|
||||
/// HTTP client for the per-user paired-Mac backup on the presence worker
|
||||
/// (`/v1/sync/paired-macs`). Auth mirrors ``PresenceClient`` /
|
||||
/// ``DeviceRegistryService``: `Authorization: Bearer <access>` plus optional
|
||||
/// `X-Cmux-Team-Id`, with tokens supplied through ``PresenceTokenSource``.
|
||||
public actor PairedMacBackupClient: PairedMacBackingUp {
|
||||
private let serviceBaseURL: String
|
||||
private let tokenSource: PresenceTokenSource
|
||||
private let teamIDProvider: @Sendable () async -> String?
|
||||
private let session: URLSession
|
||||
private let requestTimeout: TimeInterval
|
||||
|
||||
/// Create a backup client for one presence service base URL and token source.
|
||||
public init(
|
||||
serviceBaseURL: String,
|
||||
tokenSource: PresenceTokenSource,
|
||||
teamIDProvider: @escaping @Sendable () async -> String? = { nil },
|
||||
session: sending URLSession = .shared,
|
||||
requestTimeout: TimeInterval = 5
|
||||
) {
|
||||
self.serviceBaseURL = serviceBaseURL
|
||||
self.tokenSource = tokenSource
|
||||
self.teamIDProvider = teamIDProvider
|
||||
self.session = session
|
||||
self.requestTimeout = requestTimeout
|
||||
}
|
||||
|
||||
private static let path = "/v1/sync/paired-macs"
|
||||
|
||||
/// Build the paired-Mac backup endpoint from a service base URL. The base
|
||||
/// may include or omit a trailing slash, and may include a deployment base
|
||||
/// path, but must be an HTTP(S) URL.
|
||||
static func endpointURL(serviceBaseURL: String) -> URL? {
|
||||
guard var components = URLComponents(string: serviceBaseURL) else { return nil }
|
||||
switch components.scheme?.lowercased() {
|
||||
case "http", "https":
|
||||
break
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let basePath = components.path.hasSuffix("/")
|
||||
? String(components.path.dropLast())
|
||||
: components.path
|
||||
components.path = basePath + Self.path
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.url
|
||||
}
|
||||
|
||||
/// Upload backup mutations to the presence worker.
|
||||
@discardableResult
|
||||
public func upload(ops: [PairedMacBackupOp]) async -> Bool {
|
||||
let teamID = await teamIDProvider()
|
||||
return await upload(ops: ops, teamID: teamID)
|
||||
}
|
||||
|
||||
/// Upload backup mutations to the presence worker for an already-captured team.
|
||||
@discardableResult
|
||||
public func upload(ops: [PairedMacBackupOp], teamID: String?) async -> Bool {
|
||||
await upload(ops: ops, teamID: teamID, expectedUserID: nil)
|
||||
}
|
||||
|
||||
/// Upload backup mutations only if auth still belongs to the captured account.
|
||||
@discardableResult
|
||||
public func upload(ops: [PairedMacBackupOp], teamID: String?, expectedUserID: String?) async -> Bool {
|
||||
guard !ops.isEmpty else { return true }
|
||||
let body = PairedMacBackupRequestBody(ops: ops.map(PairedMacBackupOpWire.init(op:)))
|
||||
guard let data = try? JSONEncoder().encode(body),
|
||||
let request = await makeRequest(
|
||||
method: "POST",
|
||||
body: data,
|
||||
teamID: teamID,
|
||||
expectedUserID: expectedUserID
|
||||
) else {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||||
pairedMacBackupLog.warning("paired-mac backup upload failed: HTTP \(http.statusCode)")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
pairedMacBackupLog.warning("paired-mac backup upload error: \(String(describing: error), privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch every backed-up paired Mac for the current user/team scope.
|
||||
public func fetchAll() async -> [PairedMacBackupRecord]? {
|
||||
await fetchSnapshot()?.records
|
||||
}
|
||||
|
||||
/// Fetch live records and delete tombstones for the current user/team scope.
|
||||
public func fetchSnapshot() async -> PairedMacBackupSnapshot? {
|
||||
let teamID = await teamIDProvider()
|
||||
return await fetchSnapshot(teamID: teamID)
|
||||
}
|
||||
|
||||
/// Fetch every backed-up paired Mac for an already-captured user/team scope.
|
||||
public func fetchAll(teamID: String?) async -> [PairedMacBackupRecord]? {
|
||||
await fetchSnapshot(teamID: teamID)?.records
|
||||
}
|
||||
|
||||
/// Fetch live records and delete tombstones for an already-captured user/team scope.
|
||||
public func fetchSnapshot(teamID: String?) async -> PairedMacBackupSnapshot? {
|
||||
await fetchSnapshot(teamID: teamID, expectedUserID: nil)
|
||||
}
|
||||
|
||||
/// Fetch live records and tombstones only if auth still belongs to the captured account.
|
||||
public func fetchSnapshot(teamID: String?, expectedUserID: String?) async -> PairedMacBackupSnapshot? {
|
||||
guard let request = await makeRequest(
|
||||
method: "GET",
|
||||
body: nil,
|
||||
teamID: teamID,
|
||||
expectedUserID: expectedUserID
|
||||
) else { return nil }
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
|
||||
pairedMacBackupLog.warning("paired-mac backup fetch failed: HTTP \((response as? HTTPURLResponse)?.statusCode ?? -1)")
|
||||
return nil
|
||||
}
|
||||
// A 2xx with an undecodable body is a real failure, not "no hosts".
|
||||
return (try? JSONDecoder().decode(PairedMacBackupListResponse.self, from: data))?.snapshot
|
||||
} catch {
|
||||
pairedMacBackupLog.warning("paired-mac backup fetch error: \(String(describing: error), privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRequest(
|
||||
method: String,
|
||||
body: Data?,
|
||||
teamID: String?,
|
||||
expectedUserID: String?
|
||||
) async -> URLRequest? {
|
||||
guard let accessToken = await tokenSource.accessToken(expectedUserID: expectedUserID),
|
||||
let url = Self.endpointURL(serviceBaseURL: serviceBaseURL) else {
|
||||
return nil
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = requestTimeout
|
||||
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
||||
if let teamID, !teamID.isEmpty {
|
||||
request.setValue(teamID, forHTTPHeaderField: "X-Cmux-Team-Id")
|
||||
}
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
struct PairedMacBackupFailableRecord: Decodable {
|
||||
let value: PairedMacBackupRecord?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? PairedMacBackupRecord(from: decoder)
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import CMUXMobileCore
|
||||
|
||||
struct PairedMacBackupFailableRoute: Decodable {
|
||||
let value: CmxAttachRoute?
|
||||
|
||||
init(from decoder: any Decoder) {
|
||||
value = try? CmxAttachRoute(from: decoder)
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
struct PairedMacBackupListResponse: Decodable {
|
||||
let records: [PairedMacBackupRecord]
|
||||
let deletedMacDeviceIDs: [String]
|
||||
|
||||
var snapshot: PairedMacBackupSnapshot {
|
||||
PairedMacBackupSnapshot(records: records, deletedMacDeviceIDs: deletedMacDeviceIDs)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case records
|
||||
case deletedMacDeviceIDs
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
records = try c.decode([PairedMacBackupFailableRecord].self, forKey: .records)
|
||||
.compactMap(\.value)
|
||||
deletedMacDeviceIDs = ((try? c.decodeIfPresent([String].self, forKey: .deletedMacDeviceIDs)) ?? [])
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// A single paired-Mac backup mutation.
|
||||
public enum PairedMacBackupOp: Sendable, Equatable {
|
||||
/// Upsert a complete backup record.
|
||||
case upsert(PairedMacBackupRecord)
|
||||
/// Upsert host reachability/active state while preserving server-side customizations.
|
||||
case upsertPreservingCustomizations(PairedMacBackupRecord)
|
||||
/// Upsert a complete backup record as an explicit user re-add after a server tombstone.
|
||||
case revive(PairedMacBackupRecord)
|
||||
/// Revive a tombstoned record while preserving any live server-side customizations.
|
||||
case revivePreservingCustomizations(PairedMacBackupRecord)
|
||||
/// Tombstone the record with the given Mac device id.
|
||||
case delete(macDeviceID: String)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// `{ macDeviceID, deleted?, record? }` matching the server's parse.
|
||||
struct PairedMacBackupOpWire: Encodable {
|
||||
let macDeviceID: String
|
||||
let deleted: Bool?
|
||||
let reviveDeleted: Bool?
|
||||
let record: PairedMacBackupRecordWire?
|
||||
|
||||
init(op: PairedMacBackupOp) {
|
||||
switch op {
|
||||
case .upsert(let record):
|
||||
self.macDeviceID = record.macDeviceID
|
||||
self.deleted = nil
|
||||
self.reviveDeleted = nil
|
||||
self.record = PairedMacBackupRecordWire(record: record, includesCustomizations: true)
|
||||
case .upsertPreservingCustomizations(let record):
|
||||
self.macDeviceID = record.macDeviceID
|
||||
self.deleted = nil
|
||||
self.reviveDeleted = nil
|
||||
self.record = PairedMacBackupRecordWire(record: record, includesCustomizations: false)
|
||||
case .revive(let record):
|
||||
self.macDeviceID = record.macDeviceID
|
||||
self.deleted = nil
|
||||
self.reviveDeleted = true
|
||||
self.record = PairedMacBackupRecordWire(record: record, includesCustomizations: true)
|
||||
case .revivePreservingCustomizations(let record):
|
||||
self.macDeviceID = record.macDeviceID
|
||||
self.deleted = nil
|
||||
self.reviveDeleted = true
|
||||
self.record = PairedMacBackupRecordWire(record: record, includesCustomizations: false)
|
||||
case .delete(let macDeviceID):
|
||||
self.macDeviceID = macDeviceID
|
||||
self.deleted = true
|
||||
self.reviveDeleted = nil
|
||||
self.record = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
public import CMUXMobileCore
|
||||
public import Foundation
|
||||
|
||||
/// One saved-host backup record on the wire.
|
||||
public struct PairedMacBackupRecord: Codable, Sendable, Equatable {
|
||||
/// Stable device id of the Mac this backup row describes.
|
||||
public var macDeviceID: String
|
||||
/// Latest user-facing Mac name reported by the host, if any.
|
||||
public var displayName: String?
|
||||
/// Reconnect routes the phone can use to reach this Mac.
|
||||
public var routes: [CmxAttachRoute]
|
||||
/// Creation time in epoch milliseconds.
|
||||
public var createdAt: Double
|
||||
/// Last update time in epoch milliseconds.
|
||||
public var lastSeenAt: Double
|
||||
/// Whether this Mac was the active host in its account/team scope.
|
||||
public var isActive: Bool
|
||||
/// User-selected display name override.
|
||||
public var customName: String?
|
||||
/// User-selected color override, or `nil` for automatic color selection.
|
||||
public var customColor: String?
|
||||
/// User-selected icon override, or `nil` for the platform default.
|
||||
public var customIcon: String?
|
||||
|
||||
/// Create one wire backup record.
|
||||
public init(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
routes: [CmxAttachRoute],
|
||||
createdAt: Double,
|
||||
lastSeenAt: Double,
|
||||
isActive: Bool,
|
||||
customName: String? = nil,
|
||||
customColor: String? = nil,
|
||||
customIcon: String? = nil
|
||||
) {
|
||||
self.macDeviceID = macDeviceID
|
||||
self.displayName = displayName
|
||||
self.routes = routes
|
||||
self.createdAt = createdAt
|
||||
self.lastSeenAt = lastSeenAt
|
||||
self.isActive = isActive
|
||||
self.customName = customName
|
||||
self.customColor = customColor
|
||||
self.customIcon = customIcon
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case macDeviceID, displayName, routes, createdAt, lastSeenAt, isActive
|
||||
case customName, customColor, customIcon
|
||||
}
|
||||
|
||||
/// Decode one saved-host backup record, dropping unsupported route entries
|
||||
/// while preserving the rest of the record for restore.
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
macDeviceID = try c.decode(String.self, forKey: .macDeviceID)
|
||||
displayName = try c.decodeIfPresent(String.self, forKey: .displayName)
|
||||
routes = try c.decodeIfPresent([PairedMacBackupFailableRoute].self, forKey: .routes)?
|
||||
.compactMap(\.value) ?? []
|
||||
createdAt = try c.decode(Double.self, forKey: .createdAt)
|
||||
lastSeenAt = try c.decode(Double.self, forKey: .lastSeenAt)
|
||||
isActive = try c.decode(Bool.self, forKey: .isActive)
|
||||
customName = try c.decodeIfPresent(String.self, forKey: .customName)
|
||||
customColor = try c.decodeIfPresent(String.self, forKey: .customColor)
|
||||
customIcon = try c.decodeIfPresent(String.self, forKey: .customIcon)
|
||||
}
|
||||
|
||||
/// Encode custom override keys even when they are `nil`, so clears sync.
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(macDeviceID, forKey: .macDeviceID)
|
||||
try c.encodeIfPresent(displayName, forKey: .displayName)
|
||||
try c.encode(routes, forKey: .routes)
|
||||
try c.encode(createdAt, forKey: .createdAt)
|
||||
try c.encode(lastSeenAt, forKey: .lastSeenAt)
|
||||
try c.encode(isActive, forKey: .isActive)
|
||||
try c.encode(customName, forKey: .customName)
|
||||
try c.encode(customColor, forKey: .customColor)
|
||||
try c.encode(customIcon, forKey: .customIcon)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// Encodes a backup record either as an authoritative iOS customization write
|
||||
/// (custom keys present, including explicit null clears) or as a route/active
|
||||
/// refresh (custom keys absent so the worker preserves its stored values).
|
||||
struct PairedMacBackupRecordWire: Encodable {
|
||||
let record: PairedMacBackupRecord
|
||||
let includesCustomizations: Bool
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var c = encoder.container(keyedBy: PairedMacBackupRecord.CodingKeys.self)
|
||||
try c.encode(record.macDeviceID, forKey: .macDeviceID)
|
||||
try c.encodeIfPresent(record.displayName, forKey: .displayName)
|
||||
try c.encode(record.routes, forKey: .routes)
|
||||
try c.encode(record.createdAt, forKey: .createdAt)
|
||||
try c.encode(record.lastSeenAt, forKey: .lastSeenAt)
|
||||
try c.encode(record.isActive, forKey: .isActive)
|
||||
guard includesCustomizations else { return }
|
||||
try c.encode(record.customName, forKey: .customName)
|
||||
try c.encode(record.customColor, forKey: .customColor)
|
||||
try c.encode(record.customIcon, forKey: .customIcon)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/// A paired-Mac store that can re-pull the authoritative backup on demand,
|
||||
/// instead of only once per launch at sign-in.
|
||||
public protocol PairedMacBackupRefreshing: Sendable {
|
||||
/// Force a backup re-fetch and LWW merge for the signed-in scope.
|
||||
func refreshFromBackup(stackUserID: String?) async
|
||||
|
||||
/// Cancel every in-flight restore or refresh for sign-out/account switches.
|
||||
func cancelInFlightRestores() async
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
struct PairedMacBackupRequestBody: Encodable {
|
||||
let ops: [PairedMacBackupOpWire]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// A complete paired-Mac restore snapshot from the presence backup service.
|
||||
///
|
||||
/// `records` contains live saved Macs. `deletedMacDeviceIDs` contains retained
|
||||
/// delete tombstones that should remove matching local rows before live records
|
||||
/// are merged.
|
||||
public struct PairedMacBackupSnapshot: Sendable, Equatable {
|
||||
/// Live paired-Mac records, newest-first by the server's restore ordering.
|
||||
public var records: [PairedMacBackupRecord]
|
||||
|
||||
/// Mac device IDs with retained delete tombstones in this restore scope.
|
||||
public var deletedMacDeviceIDs: [String]
|
||||
|
||||
/// Create a restore snapshot from live records and retained delete IDs.
|
||||
public init(
|
||||
records: [PairedMacBackupRecord],
|
||||
deletedMacDeviceIDs: [String] = []
|
||||
) {
|
||||
self.records = records
|
||||
self.deletedMacDeviceIDs = deletedMacDeviceIDs
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/// Local outbox for paired-Mac backup tombstones that have not yet been
|
||||
/// confirmed by a successful upload.
|
||||
public protocol PairedMacPendingDeleteStoring: Sendable {
|
||||
/// Load pending tombstones for one account/team scope.
|
||||
func load(scope: String) async -> Set<String>
|
||||
|
||||
/// Replace pending tombstones for one account/team scope.
|
||||
func save(_ ids: Set<String>, scope: String) async
|
||||
|
||||
/// Clear all pending tombstones.
|
||||
func removeAll() async
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
public import CmuxMobilePairedMac
|
||||
public import Foundation
|
||||
import os
|
||||
|
||||
private let pairedMacRestoreLog = Logger(subsystem: "com.cmuxterm.app", category: "PairedMacRestore")
|
||||
|
||||
/// Restores a user's backed-up saved hosts into the local
|
||||
/// ``MobilePairedMacStore`` on sign-in (the mirror image of
|
||||
/// ``PairedMacMigration``). This is what makes saved hosts and their IPs —
|
||||
/// including manually typed ones — reappear after a reinstall or a bundle-id
|
||||
/// change, where the local SQLite container is empty.
|
||||
///
|
||||
/// Local stays authoritative: a host present in BOTH places keeps the local copy
|
||||
/// when local's `lastSeenAt` is at least as recent as the backup's (last-writer-
|
||||
/// wins by `lastSeenAt`), so a fresh local edit is never clobbered by an older
|
||||
/// backup. Only hosts missing locally, or whose backup is strictly newer, are
|
||||
/// written. The active selection is only honored from the backup when the local
|
||||
/// store has NO active host (the fresh-install case), so restoring never hijacks
|
||||
/// a host the user is actively using on this device.
|
||||
public struct PairedMacRestore: Sendable {
|
||||
private let store: any MobilePairedMacStoring
|
||||
private let backup: any PairedMacBackingUp
|
||||
|
||||
/// Create a restore coordinator over a local paired-Mac store and backup source.
|
||||
public init(store: any MobilePairedMacStoring, backup: any PairedMacBackingUp) {
|
||||
self.store = store
|
||||
self.backup = backup
|
||||
}
|
||||
|
||||
/// Merge the user's backup into the local store. A fetch failure leaves the
|
||||
/// local store untouched and reports `completed: false` so the caller can
|
||||
/// retry; a successful fetch (even of an empty list) reports `completed:
|
||||
/// true`.
|
||||
/// - Parameter teamID: the Stack team this restore is for. The backup fetch is
|
||||
/// already server-scoped to that team (`X-Cmux-Team-Id`), so every restored
|
||||
/// row is stamped with it; this is what scopes the local list per team. `nil`
|
||||
/// when no team is selected (rows stay team-less / visible everywhere).
|
||||
@discardableResult
|
||||
public func run(
|
||||
accountID: String,
|
||||
teamID: String? = nil,
|
||||
now: Date = Date(),
|
||||
boundary: PairedMacRestoreBoundary? = nil,
|
||||
boundaryGeneration: UInt64? = nil,
|
||||
locallyDeletedMacDeviceIDs: Set<String> = []
|
||||
) async -> RestoreOutcome {
|
||||
func isCurrent() -> Bool {
|
||||
guard !Task.isCancelled else { return false }
|
||||
guard let boundary, let boundaryGeneration else { return true }
|
||||
return boundary.isCurrent(boundaryGeneration)
|
||||
}
|
||||
|
||||
guard let snapshot = await backup.fetchSnapshot(teamID: teamID, expectedUserID: accountID) else {
|
||||
return RestoreOutcome(completed: false, restored: 0)
|
||||
}
|
||||
// Sign-out (or any wipe) can race this restore: if the owning task was
|
||||
// cancelled while the network fetch was suspended, do NOT write the
|
||||
// previous account's Macs back into the just-emptied local store. Report
|
||||
// `completed: false` so the caller does not memoize a non-restore.
|
||||
if !isCurrent() {
|
||||
return RestoreOutcome(completed: false, restored: 0)
|
||||
}
|
||||
let tombstoneIDs = Set(snapshot.deletedMacDeviceIDs
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty })
|
||||
.union(locallyDeletedMacDeviceIDs)
|
||||
let liveRecords = snapshot.records.filter { !tombstoneIDs.contains($0.macDeviceID) }
|
||||
guard !liveRecords.isEmpty || !tombstoneIDs.isEmpty else {
|
||||
return RestoreOutcome(completed: true, restored: 0)
|
||||
}
|
||||
|
||||
let localBeforeTombstones = (try? await store.loadAll(stackUserID: accountID, teamID: teamID)) ?? []
|
||||
// The fetch is not the only sign-out window: re-check after the load too,
|
||||
// before we start writing (a wipe between fetch and load must not be
|
||||
// overwritten with the old account's Macs).
|
||||
if !isCurrent() {
|
||||
return RestoreOutcome(completed: false, restored: 0)
|
||||
}
|
||||
for macDeviceID in tombstoneIDs {
|
||||
if !isCurrent() {
|
||||
return RestoreOutcome(completed: false, restored: 0)
|
||||
}
|
||||
do {
|
||||
try await store.remove(macDeviceID: macDeviceID, stackUserID: accountID, teamID: teamID)
|
||||
} catch {
|
||||
pairedMacRestoreLog.warning(
|
||||
"failed to apply paired mac tombstone \(macDeviceID, privacy: .public): \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
}
|
||||
}
|
||||
let local = tombstoneIDs.isEmpty
|
||||
? localBeforeTombstones
|
||||
: ((try? await store.loadAll(stackUserID: accountID, teamID: teamID)) ?? [])
|
||||
if !isCurrent() {
|
||||
return RestoreOutcome(completed: false, restored: 0)
|
||||
}
|
||||
var localByID: [String: MobilePairedMac] = [:]
|
||||
for mac in local { localByID[mac.macDeviceID] = mac }
|
||||
// On a fresh install (no local active host) honor the backup's active
|
||||
// flag so auto-reconnect targets the last host; otherwise never disturb
|
||||
// the device's current active selection.
|
||||
let hasLocalActive = local.contains { $0.isActive }
|
||||
|
||||
var restored = 0
|
||||
for record in liveRecords {
|
||||
// Re-check before EVERY write: a sign-out wipe can land between any two
|
||||
// upserts, and writes after it would reinsert the previous account's
|
||||
// Macs into the emptied store. Stop the moment we are cancelled.
|
||||
if !isCurrent() {
|
||||
return RestoreOutcome(completed: false, restored: restored)
|
||||
}
|
||||
let backupSeconds = record.lastSeenAt / 1000.0
|
||||
if let existing = localByID[record.macDeviceID],
|
||||
existing.lastSeenAt.timeIntervalSince1970 >= backupSeconds {
|
||||
continue // local is at least as fresh: keep it (local authoritative)
|
||||
}
|
||||
// Active flag policy: when this record already exists locally we are
|
||||
// only refreshing its route/name (the backup is fresher), so PRESERVE
|
||||
// its current local active flag — otherwise a route refresh of the
|
||||
// active Mac (e.g. `refreshFromBackup` right before reconnect/
|
||||
// aggregation) would silently deactivate it and lose the user's
|
||||
// selection. For a record missing locally, honor the backup's active
|
||||
// only on a fresh install (no local active host); never hijack an
|
||||
// existing active selection.
|
||||
let markActive: Bool
|
||||
if let existing = localByID[record.macDeviceID] {
|
||||
markActive = existing.isActive
|
||||
} else {
|
||||
markActive = hasLocalActive ? false : record.isActive
|
||||
}
|
||||
do {
|
||||
let backupDate = Date(timeIntervalSince1970: backupSeconds)
|
||||
try await store.upsert(
|
||||
macDeviceID: record.macDeviceID,
|
||||
displayName: record.displayName,
|
||||
routes: record.routes,
|
||||
markActive: markActive,
|
||||
stackUserID: accountID,
|
||||
teamID: teamID,
|
||||
now: backupDate
|
||||
)
|
||||
if !isCurrent() {
|
||||
if localByID[record.macDeviceID] == nil {
|
||||
try? await store.remove(
|
||||
macDeviceID: record.macDeviceID,
|
||||
stackUserID: accountID,
|
||||
teamID: teamID
|
||||
)
|
||||
}
|
||||
return RestoreOutcome(completed: false, restored: restored)
|
||||
}
|
||||
// Apply the user customizations from the (fresher) backup so a
|
||||
// rename / color / icon set on another device lands here. Set
|
||||
// verbatim (including nil) so a cleared override clears here too;
|
||||
// `upsert` preserves customizations, so this is the only writer.
|
||||
try await store.setCustomization(
|
||||
macDeviceID: record.macDeviceID,
|
||||
customName: record.customName,
|
||||
customColor: record.customColor,
|
||||
customIcon: record.customIcon,
|
||||
stackUserID: accountID,
|
||||
teamID: teamID,
|
||||
now: backupDate
|
||||
)
|
||||
if !isCurrent() {
|
||||
return RestoreOutcome(completed: false, restored: restored)
|
||||
}
|
||||
restored += 1
|
||||
} catch {
|
||||
pairedMacRestoreLog.warning(
|
||||
"failed to restore paired mac \(record.macDeviceID, privacy: .public): \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
}
|
||||
}
|
||||
if restored > 0 {
|
||||
pairedMacRestoreLog.info("restored \(restored, privacy: .public) paired mac(s) from backup")
|
||||
}
|
||||
return RestoreOutcome(completed: true, restored: restored)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Synchronous restore invalidation token shared by the UI boundary and the
|
||||
/// backup actor. `signOut()` and team switches are synchronous UI methods, so
|
||||
/// they need a non-actor way to invalidate restore writes before any async
|
||||
/// cancellation task gets scheduled.
|
||||
public final class PairedMacRestoreBoundary: @unchecked Sendable {
|
||||
// Justification: this boundary must be synchronously invalidated from
|
||||
// sign-out/team-switch UI code before async cleanup tasks are scheduled.
|
||||
// Making it an actor would move that ordering behind an `await`.
|
||||
private let lock = NSLock()
|
||||
private var value: UInt64 = 0
|
||||
|
||||
/// Create a restore boundary at generation zero.
|
||||
public init() {}
|
||||
|
||||
/// The current restore generation.
|
||||
public var generation: UInt64 {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Invalidate every restore that captured an older generation.
|
||||
public func invalidate() {
|
||||
lock.lock()
|
||||
value &+= 1
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Whether a restore that captured `generation` is still allowed to write.
|
||||
public func isCurrent(_ generation: UInt64) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return value == generation
|
||||
}
|
||||
}
|
||||
+75
-14
@@ -15,12 +15,15 @@ public import Foundation
|
||||
/// background wake. Every operation reads and writes the defaults directly
|
||||
/// (no in-memory copy), so the separate instances owned by the push
|
||||
/// coordinator and the shell composite stay coherent over the shared storage.
|
||||
/// Holds opaque notification UUIDs only, never content. `@MainActor` because
|
||||
/// both writers (push coordinator, shell composite) are main-actor isolated.
|
||||
/// Holds opaque notification UUIDs plus the owning Mac id only, never content.
|
||||
/// `@MainActor` because both writers (push coordinator, shell composite) are
|
||||
/// main-actor isolated.
|
||||
@MainActor
|
||||
public final class PendingNotificationDismissQueue {
|
||||
private let defaults: UserDefaults
|
||||
private static let key = "cmux.notifications.pendingMacDismissIds"
|
||||
private static let idKey = "id"
|
||||
private static let macDeviceIDKey = "macDeviceId"
|
||||
/// FIFO bound; a phone cannot meaningfully accumulate more un-synced
|
||||
/// dismissals than this, and the Mac ignores unknown ids anyway.
|
||||
private static let capacity = 128
|
||||
@@ -32,38 +35,96 @@ public final class PendingNotificationDismissQueue {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// The ids waiting to be delivered to the Mac, oldest first.
|
||||
/// The dismisses waiting to be delivered, oldest first. Older builds stored
|
||||
/// only a string array of ids; those are still readable and route through the
|
||||
/// foreground Mac on the next flush.
|
||||
public var pendingDismisses: [(id: String, macDeviceID: String?)] {
|
||||
if let rows = defaults.array(forKey: Self.key) as? [[String: String]] {
|
||||
return rows.compactMap { row in
|
||||
guard let id = row[Self.idKey]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!id.isEmpty else { return nil }
|
||||
let mac = row[Self.macDeviceIDKey]?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (id: id, macDeviceID: mac?.isEmpty == false ? mac : nil)
|
||||
}
|
||||
}
|
||||
return (defaults.stringArray(forKey: Self.key) ?? []).map { (id: $0, macDeviceID: nil) }
|
||||
}
|
||||
|
||||
/// The ids waiting to be delivered, oldest first.
|
||||
public var pendingIDs: [String] {
|
||||
defaults.stringArray(forKey: Self.key) ?? []
|
||||
pendingDismisses.map(\.id)
|
||||
}
|
||||
|
||||
/// Add dismissed notification ids to the outbox. Blank ids are dropped,
|
||||
/// duplicates are kept once, and the oldest entries are evicted past
|
||||
/// ``capacity``.
|
||||
public func enqueue(_ ids: [String]) {
|
||||
let trimmed = ids
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
public func enqueue(_ ids: [String], macDeviceID: String? = nil) {
|
||||
let mac = macDeviceID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
enqueue(ids.map { (id: $0, macDeviceID: mac?.isEmpty == false ? mac : nil) })
|
||||
}
|
||||
|
||||
/// Add dismissed notification ids with their owning Mac ids to the outbox.
|
||||
public func enqueue(_ dismisses: [(id: String, macDeviceID: String?)]) {
|
||||
let trimmed = dismisses.compactMap { dismiss -> (id: String, macDeviceID: String?)? in
|
||||
let id = dismiss.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !id.isEmpty else { return nil }
|
||||
let mac = dismiss.macDeviceID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (id: id, macDeviceID: mac?.isEmpty == false ? mac : nil)
|
||||
}
|
||||
guard !trimmed.isEmpty else { return }
|
||||
var pending = pendingIDs
|
||||
for id in trimmed where !pending.contains(id) {
|
||||
pending.append(id)
|
||||
var pending = pendingDismisses
|
||||
for dismiss in trimmed where !pending.contains(where: { $0.id == dismiss.id && $0.macDeviceID == dismiss.macDeviceID }) {
|
||||
pending.append(dismiss)
|
||||
}
|
||||
if pending.count > Self.capacity {
|
||||
pending.removeFirst(pending.count - Self.capacity)
|
||||
}
|
||||
defaults.set(pending, forKey: Self.key)
|
||||
defaults.set(
|
||||
pending.map { dismiss in
|
||||
var row = [Self.idKey: dismiss.id]
|
||||
if let mac = dismiss.macDeviceID {
|
||||
row[Self.macDeviceIDKey] = mac
|
||||
}
|
||||
return row
|
||||
},
|
||||
forKey: Self.key
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove ids that were confirmed delivered to the Mac.
|
||||
public func remove(_ ids: [String]) {
|
||||
guard !ids.isEmpty else { return }
|
||||
let removal = Set(ids)
|
||||
let remaining = pendingIDs.filter { !removal.contains($0) }
|
||||
let remaining = pendingDismisses.filter { !removal.contains($0.id) }
|
||||
save(remaining)
|
||||
}
|
||||
|
||||
/// Remove dismisses that were confirmed delivered to the owning Mac.
|
||||
public func remove(_ dismisses: [(id: String, macDeviceID: String?)]) {
|
||||
guard !dismisses.isEmpty else { return }
|
||||
func key(_ dismiss: (id: String, macDeviceID: String?)) -> String {
|
||||
"\(dismiss.macDeviceID ?? "")\u{1F}\(dismiss.id)"
|
||||
}
|
||||
let removal = Set(dismisses.map(key))
|
||||
let remaining = pendingDismisses.filter { !removal.contains(key($0)) }
|
||||
save(remaining)
|
||||
}
|
||||
|
||||
private func save(_ dismisses: [(id: String, macDeviceID: String?)]) {
|
||||
let remaining = dismisses.filter { !$0.id.isEmpty }
|
||||
if remaining.isEmpty {
|
||||
defaults.removeObject(forKey: Self.key)
|
||||
} else {
|
||||
defaults.set(remaining, forKey: Self.key)
|
||||
defaults.set(
|
||||
remaining.map { dismiss in
|
||||
var row = [Self.idKey: dismiss.id]
|
||||
if let mac = dismiss.macDeviceID {
|
||||
row[Self.macDeviceIDKey] = mac
|
||||
}
|
||||
return row
|
||||
},
|
||||
forKey: Self.key
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public struct PresenceInstance: Codable, Equatable, Sendable {
|
||||
public var platform: String
|
||||
/// Human-readable device name, when the host announced one.
|
||||
public var displayName: String?
|
||||
/// The host app's bundle id, when reported. Lets the UI label the build
|
||||
/// channel (Stable / Nightly / RC / DEV) — see ``MacBuildChannel``. `nil` for
|
||||
/// an older host that doesn't announce it.
|
||||
public var bundleId: String?
|
||||
/// Capability strings announced by the host instance.
|
||||
public var capabilities: [String]
|
||||
/// Whether the instance is currently considered online by the service.
|
||||
@@ -37,6 +41,7 @@ public struct PresenceInstance: Codable, Equatable, Sendable {
|
||||
case tag
|
||||
case platform
|
||||
case displayName
|
||||
case bundleId
|
||||
case capabilities
|
||||
case online
|
||||
case lastSeenAt
|
||||
@@ -61,6 +66,7 @@ public struct PresenceInstance: Codable, Equatable, Sendable {
|
||||
tag = try container.decode(String.self, forKey: .tag)
|
||||
platform = try container.decode(String.self, forKey: .platform)
|
||||
displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
|
||||
bundleId = try container.decodeIfPresent(String.self, forKey: .bundleId)
|
||||
capabilities = try container.decode([String].self, forKey: .capabilities)
|
||||
online = try container.decode(Bool.self, forKey: .online)
|
||||
lastSeenAt = try container.decode(Double.self, forKey: .lastSeenAt)
|
||||
@@ -75,6 +81,7 @@ public struct PresenceInstance: Codable, Equatable, Sendable {
|
||||
tag: String,
|
||||
platform: String,
|
||||
displayName: String? = nil,
|
||||
bundleId: String? = nil,
|
||||
capabilities: [String] = [],
|
||||
online: Bool,
|
||||
lastSeenAt: Double,
|
||||
@@ -86,6 +93,7 @@ public struct PresenceInstance: Codable, Equatable, Sendable {
|
||||
self.tag = tag
|
||||
self.platform = platform
|
||||
self.displayName = displayName
|
||||
self.bundleId = bundleId
|
||||
self.capabilities = capabilities
|
||||
self.online = online
|
||||
self.lastSeenAt = lastSeenAt
|
||||
|
||||
@@ -11,10 +11,16 @@ public struct PresenceMap: Equatable, Sendable {
|
||||
public struct DeviceSummary: Equatable, Sendable {
|
||||
public var online: Bool
|
||||
public var lastSeenAt: Date
|
||||
/// The host's build-channel label (`"DEV · tag"`, `"Nightly"`, `"Stable"`,
|
||||
/// …), derived from its reported bundle id + tag. `nil` when not
|
||||
/// identifiable (older host). See ``MacBuildChannel``.
|
||||
public var buildLabel: String?
|
||||
|
||||
public init(online: Bool, lastSeenAt: Date) {
|
||||
/// Create one device-level presence rollup.
|
||||
public init(online: Bool, lastSeenAt: Date, buildLabel: String? = nil) {
|
||||
self.online = online
|
||||
self.lastSeenAt = lastSeenAt
|
||||
self.buildLabel = buildLabel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +83,24 @@ public struct PresenceMap: Equatable, Sendable {
|
||||
guard let instances = instancesByDevice[deviceId], !instances.isEmpty else { return nil }
|
||||
var online = false
|
||||
var lastSeenMs = -Double.infinity
|
||||
// Pick the instance to label the build from: prefer an online one (the
|
||||
// build actually running), then the freshest. A device usually has one.
|
||||
var labelInstance: PresenceInstance?
|
||||
for instance in instances.values {
|
||||
online = online || instance.online
|
||||
lastSeenMs = max(lastSeenMs, instance.lastSeenAt)
|
||||
if let current = labelInstance {
|
||||
let better = (instance.online && !current.online)
|
||||
|| (instance.online == current.online && instance.lastSeenAt > current.lastSeenAt)
|
||||
if better { labelInstance = instance }
|
||||
} else {
|
||||
labelInstance = instance
|
||||
}
|
||||
}
|
||||
return DeviceSummary(
|
||||
online: online,
|
||||
lastSeenAt: Date(timeIntervalSince1970: lastSeenMs / 1000)
|
||||
lastSeenAt: Date(timeIntervalSince1970: lastSeenMs / 1000),
|
||||
buildLabel: MacBuildChannel().label(bundleID: labelInstance?.bundleId, tag: labelInstance?.tag)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+20
-3
@@ -12,24 +12,41 @@ extension PresenceClient {
|
||||
public static let serviceURLEnvKey = "CMUX_PRESENCE_BASE_URL"
|
||||
/// UserDefaults override, mirroring the Mac's `presenceServiceURL`.
|
||||
public static let serviceURLDefaultsKey = "presenceServiceURL"
|
||||
/// Info.plist override key. A tapped iOS device app sees no shell env, so the
|
||||
/// reload scripts BAKE this into the tagged build's Info.plist (from
|
||||
/// `CMUX_PRESENCE_BASE_URL`) to point the build at a per-developer isolated
|
||||
/// worker (see workers/presence/scripts/deploy-dev.sh). This is how several
|
||||
/// people dogfood the presence/backup worker at once without sharing one
|
||||
/// instance.
|
||||
public static let serviceURLInfoPlistKey = "CMUXPresenceBaseURL"
|
||||
/// The dev/staging worker (dev Stack project); see workers/presence/README.md.
|
||||
public static let debugDefaultServiceURL = "https://cmux-presence-dev.debussy.workers.dev"
|
||||
/// The production presence worker (prod Stack project); see
|
||||
/// workers/presence/README.md. The Release default, so a stable iOS app
|
||||
/// subscribes to the same presence service stable Macs heartbeat to.
|
||||
public static let productionServiceURL = "https://presence.cmux.dev"
|
||||
|
||||
/// The presence service base URL for this process, or `nil` when presence
|
||||
/// is disabled (no override and not a Debug build).
|
||||
/// The presence service base URL for this process. Override precedence: env,
|
||||
/// then UserDefaults, then the baked Info.plist value, then the build default
|
||||
/// (dev worker on Debug, production worker on Release). Never `nil` now — the
|
||||
/// phone always has a presence service to subscribe to; whether a given Mac
|
||||
/// shows up depends on that Mac heartbeating (mobile enabled) to the same one.
|
||||
public static func resolvedServiceBaseURL(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
defaults: UserDefaults = .standard,
|
||||
infoPlistValue: String? = Bundle.main.object(forInfoDictionaryKey: serviceURLInfoPlistKey) as? String,
|
||||
isDebugBuild: Bool = PresenceClient.isDebugBuild
|
||||
) -> String? {
|
||||
let override = environment[serviceURLEnvKey]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
?? defaults.string(forKey: serviceURLDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
?? infoPlistValue?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let override, !override.isEmpty {
|
||||
return override
|
||||
}
|
||||
return isDebugBuild ? debugDefaultServiceURL : nil
|
||||
return isDebugBuild ? debugDefaultServiceURL : productionServiceURL
|
||||
}
|
||||
|
||||
/// Whether this is a Debug build (compile-time; parameterized above so the
|
||||
|
||||
@@ -6,9 +6,24 @@ public struct PresenceTokenSource: Sendable {
|
||||
/// Returns the current Stack access token, or nil when there is no
|
||||
/// session.
|
||||
public var accessToken: @Sendable () async -> String?
|
||||
/// Returns the current Stack user id, or nil when there is no session.
|
||||
public var currentUserID: @Sendable () async -> String?
|
||||
|
||||
/// Creates a token source backed by the given closure.
|
||||
public init(accessToken: @escaping @Sendable () async -> String?) {
|
||||
public init(
|
||||
accessToken: @escaping @Sendable () async -> String?,
|
||||
currentUserID: @escaping @Sendable () async -> String? = { nil }
|
||||
) {
|
||||
self.accessToken = accessToken
|
||||
self.currentUserID = currentUserID
|
||||
}
|
||||
|
||||
/// Read a token only when auth still belongs to the captured account.
|
||||
public func accessToken(expectedUserID: String?) async -> String? {
|
||||
guard let expectedUserID else { return await accessToken() }
|
||||
guard await currentUserID() == expectedUserID else { return nil }
|
||||
let token = await accessToken()
|
||||
guard token != nil, await currentUserID() == expectedUserID else { return nil }
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import CMUXMobileCore
|
||||
|
||||
/// Resolve the id used to key the foreground Mac's workspace state.
|
||||
extension CmxAttachTicket {
|
||||
func foregroundMacID(hint: String?) -> String {
|
||||
if let hint, !hint.isEmpty, !hint.hasPrefix("manual-") { return hint }
|
||||
return macDeviceID
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/// The result of one paired-Mac restore attempt.
|
||||
public struct RestoreOutcome: Sendable, Equatable {
|
||||
/// Whether the backup fetch succeeded, even if it returned no hosts.
|
||||
public let completed: Bool
|
||||
/// Number of backup records written into the local store.
|
||||
public let restored: Int
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
|
||||
/// The live client to a secondary Mac plus the route/ticket it was dialed on.
|
||||
struct SecondaryClientHandle {
|
||||
let client: MobileCoreRPCClient
|
||||
let route: CmxAttachRoute
|
||||
let ticket: CmxAttachTicket
|
||||
let supportedHostCapabilities: Set<String>
|
||||
let actionCapabilities: MobileWorkspaceActionCapabilities
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
|
||||
/// One non-foreground Mac's persistent read-only connection plus its event consumer.
|
||||
@MainActor
|
||||
final class SecondaryMacSubscription {
|
||||
let macDeviceID: String
|
||||
let client: MobileCoreRPCClient
|
||||
/// The route and ticket this client was dialed on, kept for promotion.
|
||||
let route: CmxAttachRoute
|
||||
let ticket: CmxAttachTicket
|
||||
/// Raw host capabilities reported by this secondary Mac.
|
||||
let supportedHostCapabilities: Set<String>
|
||||
/// Workspace action capabilities reported by this secondary Mac.
|
||||
let actionCapabilities: MobileWorkspaceActionCapabilities
|
||||
/// Per-connection stream id for the `mobile.events.subscribe` handshake.
|
||||
let streamID: String
|
||||
var task: Task<Void, Never>?
|
||||
/// Coalesces hot `workspace.updated` bursts to one leading and one trailing fetch.
|
||||
var refreshTask: Task<Void, Never>?
|
||||
var refreshPending = false
|
||||
|
||||
init(
|
||||
macDeviceID: String,
|
||||
client: MobileCoreRPCClient,
|
||||
route: CmxAttachRoute,
|
||||
ticket: CmxAttachTicket,
|
||||
supportedHostCapabilities: Set<String>,
|
||||
actionCapabilities: MobileWorkspaceActionCapabilities
|
||||
) {
|
||||
self.macDeviceID = macDeviceID
|
||||
self.client = client
|
||||
self.route = route
|
||||
self.ticket = ticket
|
||||
self.supportedHostCapabilities = supportedHostCapabilities
|
||||
self.actionCapabilities = actionCapabilities
|
||||
self.streamID = "ios-secondary-events-\(macDeviceID)-\(UUID().uuidString)"
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
let client = self.client
|
||||
Task { await client.disconnect() }
|
||||
}
|
||||
|
||||
/// Stop the read-only consumer loops while keeping the client connected.
|
||||
func detachKeepingClient() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
public import CMUXMobileCore
|
||||
public import CmuxMobilePairedMac
|
||||
public import Foundation
|
||||
|
||||
/// A ``MobilePairedMacStoring`` decorator that injects the currently-selected
|
||||
/// Stack team when shell call sites use the legacy convenience overloads.
|
||||
///
|
||||
/// The paired-Mac store itself supports explicit `teamID` parameters, but most
|
||||
/// shell code intentionally depends on the older `loadAll(stackUserID:)` /
|
||||
/// `upsert(... stackUserID:now:)` helpers. Keeping team scoping as a composition
|
||||
/// decorator makes that boundary independent from backup mirroring: Release
|
||||
/// builds still stamp and read rows by selected team even when the cloud backup
|
||||
/// feature flag is off.
|
||||
public struct TeamScopedPairedMacStore: MobilePairedMacStoring {
|
||||
private let inner: any MobilePairedMacStoring
|
||||
private let teamIDProvider: @Sendable () async -> String?
|
||||
|
||||
/// Wrap a paired-Mac store with selected-team scoping.
|
||||
/// - Parameters:
|
||||
/// - inner: The underlying paired-Mac store.
|
||||
/// - teamIDProvider: Live selected-team lookup from the auth coordinator.
|
||||
public init(
|
||||
inner: any MobilePairedMacStoring,
|
||||
teamIDProvider: @escaping @Sendable () async -> String?
|
||||
) {
|
||||
self.inner = inner
|
||||
self.teamIDProvider = teamIDProvider
|
||||
}
|
||||
|
||||
/// Insert or update a paired Mac, using the explicit team when present or
|
||||
/// the currently-selected team otherwise.
|
||||
public func upsert(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
routes: [CmxAttachRoute],
|
||||
markActive: Bool,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
try await inner.upsert(
|
||||
macDeviceID: macDeviceID,
|
||||
displayName: displayName,
|
||||
routes: routes,
|
||||
markActive: markActive,
|
||||
stackUserID: stackUserID,
|
||||
teamID: await resolvedTeam(teamID),
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
/// Load paired Macs scoped to the explicit team when present or the
|
||||
/// currently-selected team otherwise.
|
||||
public func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac] {
|
||||
try await inner.loadAll(stackUserID: stackUserID, teamID: await resolvedTeam(teamID))
|
||||
}
|
||||
|
||||
/// Return the active paired Mac scoped to the explicit team when present or
|
||||
/// the currently-selected team otherwise.
|
||||
public func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? {
|
||||
try await inner.activeMac(stackUserID: stackUserID, teamID: await resolvedTeam(teamID))
|
||||
}
|
||||
|
||||
/// Mark one paired Mac active in the selected team scope.
|
||||
public func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {
|
||||
let team = await resolvedTeam(teamID)
|
||||
let scope = try await visibleScope(macDeviceID: macDeviceID, stackUserID: stackUserID, teamID: team)
|
||||
if scope.teamID != team {
|
||||
try await inner.clearActive(stackUserID: scope.stackUserID, teamID: team)
|
||||
}
|
||||
try await inner.setActive(
|
||||
macDeviceID: macDeviceID,
|
||||
stackUserID: scope.stackUserID,
|
||||
teamID: scope.teamID
|
||||
)
|
||||
}
|
||||
|
||||
/// Clear the active paired Mac in the selected team scope.
|
||||
public func clearActive(stackUserID: String?, teamID: String?) async throws {
|
||||
try await inner.clearActive(stackUserID: stackUserID, teamID: await resolvedTeam(teamID))
|
||||
}
|
||||
|
||||
/// Persist local customizations without changing the row's team scope.
|
||||
public func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws {
|
||||
let team = await resolvedTeam(teamID)
|
||||
let scope = try await visibleScope(macDeviceID: macDeviceID, stackUserID: stackUserID, teamID: team)
|
||||
try await inner.setCustomization(
|
||||
macDeviceID: macDeviceID,
|
||||
customName: customName,
|
||||
customColor: customColor,
|
||||
customIcon: customIcon,
|
||||
stackUserID: scope.stackUserID,
|
||||
teamID: scope.teamID,
|
||||
now: now
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove one paired Mac in the selected team scope.
|
||||
public func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {
|
||||
let team = await resolvedTeam(teamID)
|
||||
let scope = try await visibleScope(macDeviceID: macDeviceID, stackUserID: stackUserID, teamID: team)
|
||||
try await inner.remove(
|
||||
macDeviceID: macDeviceID,
|
||||
stackUserID: scope.stackUserID,
|
||||
teamID: scope.teamID
|
||||
)
|
||||
}
|
||||
|
||||
/// Remove all paired Macs.
|
||||
public func removeAll() async throws {
|
||||
try await inner.removeAll()
|
||||
}
|
||||
|
||||
private func resolvedTeam(_ teamID: String?) async -> String? {
|
||||
if let teamID { return teamID }
|
||||
return await teamIDProvider()
|
||||
}
|
||||
|
||||
private func visibleScope(
|
||||
macDeviceID: String,
|
||||
stackUserID: String?,
|
||||
teamID: String?
|
||||
) async throws -> (stackUserID: String?, teamID: String?) {
|
||||
let visibleMac = try await inner.loadAll(stackUserID: stackUserID, teamID: teamID)
|
||||
.first { $0.macDeviceID == macDeviceID }
|
||||
guard let visibleMac else {
|
||||
return (stackUserID, teamID)
|
||||
}
|
||||
return (visibleMac.stackUserID, visibleMac.teamID)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
public import Foundation
|
||||
|
||||
/// UserDefaults-backed pending-delete store for production. The values are only
|
||||
/// Mac device IDs keyed by Stack account/team scope; no routes or hostnames are
|
||||
/// stored in this outbox.
|
||||
public actor UserDefaultsPairedMacPendingDeleteStore: PairedMacPendingDeleteStoring {
|
||||
private let defaults: UserDefaults
|
||||
private let key: String
|
||||
|
||||
/// Create a durable pending-delete store.
|
||||
public init(
|
||||
defaults: UserDefaults = .standard,
|
||||
key: String = "cmux.mobile.pairedMacBackup.pendingDeletes.v1"
|
||||
) {
|
||||
self.defaults = defaults
|
||||
self.key = key
|
||||
}
|
||||
|
||||
/// Create a durable pending-delete store in a named UserDefaults suite.
|
||||
public init(
|
||||
suiteName: String,
|
||||
key: String = "cmux.mobile.pairedMacBackup.pendingDeletes.v1"
|
||||
) {
|
||||
self.defaults = UserDefaults(suiteName: suiteName) ?? .standard
|
||||
self.key = key
|
||||
}
|
||||
|
||||
/// Load pending tombstones for one account/team scope.
|
||||
public func load(scope: String) async -> Set<String> {
|
||||
let all = defaults.dictionary(forKey: key) as? [String: [String]] ?? [:]
|
||||
return Set(all[scope] ?? [])
|
||||
}
|
||||
|
||||
/// Replace pending tombstones for one account/team scope.
|
||||
public func save(_ ids: Set<String>, scope: String) async {
|
||||
var all = defaults.dictionary(forKey: key) as? [String: [String]] ?? [:]
|
||||
if ids.isEmpty {
|
||||
all.removeValue(forKey: scope)
|
||||
} else {
|
||||
all[scope] = ids.sorted()
|
||||
}
|
||||
defaults.set(all, forKey: key)
|
||||
}
|
||||
|
||||
/// Clear all pending tombstones.
|
||||
public func removeAll() async {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
|
||||
/// Routing target for a workspace mutation in the aggregated multi-Mac list.
|
||||
struct WorkspaceMutationTarget {
|
||||
let client: MobileCoreRPCClient?
|
||||
let isForeground: Bool
|
||||
let macDeviceID: String?
|
||||
}
|
||||
+4
-4
@@ -202,9 +202,9 @@ import Testing
|
||||
// A workspace sync now reports only term-a (term-b was closed). Setting
|
||||
// `workspaces` is the single topology funnel; its `didSet` must prune the
|
||||
// staged bytes for the terminal that disappeared.
|
||||
composite.workspaces = [
|
||||
composite.setWorkspacesForTesting([
|
||||
MobileWorkspacePreview(id: "ws-1", name: "ws", terminals: [Self.terminalA]),
|
||||
]
|
||||
])
|
||||
|
||||
#expect(composite.pendingAttachments(forTerminalID: "term-b").isEmpty)
|
||||
#expect(composite.pendingAttachments(forTerminalID: "term-a").count == 1)
|
||||
@@ -219,10 +219,10 @@ import Testing
|
||||
|
||||
// term-b moves to a second workspace; it is still in topology, so its
|
||||
// attachments survive.
|
||||
composite.workspaces = [
|
||||
composite.setWorkspacesForTesting([
|
||||
MobileWorkspacePreview(id: "ws-1", name: "ws", terminals: [Self.terminalA]),
|
||||
MobileWorkspacePreview(id: "ws-2", name: "ws2", terminals: [Self.terminalB]),
|
||||
]
|
||||
])
|
||||
|
||||
#expect(composite.pendingAttachments(forTerminalID: "term-b").count == 1)
|
||||
}
|
||||
|
||||
+62
-4
@@ -40,9 +40,9 @@ actor RoutingHostRouter {
|
||||
var surfaceID: String
|
||||
var text: String
|
||||
}
|
||||
|
||||
private(set) var pasteImages: [PasteImageRecord] = []
|
||||
private(set) var pastes: [PasteRecord] = []
|
||||
private(set) var dismisses: [(notificationIDs: [String], clientID: String?)] = []
|
||||
/// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all.
|
||||
private var rejectPasteImageFromIndex: Int?
|
||||
private var holdFirstPasteImage = false
|
||||
@@ -89,6 +89,7 @@ actor RoutingHostRouter {
|
||||
|
||||
func recordedPasteImages() -> [PasteImageRecord] { pasteImages }
|
||||
func recordedPastes() -> [PasteRecord] { pastes }
|
||||
func recordedDismisses() -> [(notificationIDs: [String], clientID: String?)] { dismisses }
|
||||
|
||||
/// Sendable extract of the request fields the router needs, pulled off the
|
||||
/// non-Sendable params dictionary before crossing the Task boundary.
|
||||
@@ -98,6 +99,8 @@ actor RoutingHostRouter {
|
||||
var surfaceID: String?
|
||||
var imageFormat: String?
|
||||
var text: String?
|
||||
var notificationIDs: [String]?
|
||||
var clientID: String?
|
||||
}
|
||||
|
||||
func response(_ info: RequestInfo) async -> Data? {
|
||||
@@ -163,6 +166,12 @@ actor RoutingHostRouter {
|
||||
let text = info.text ?? ""
|
||||
pastes.append(PasteRecord(surfaceID: surfaceID, text: text))
|
||||
return try? Self.resultFrame(id: id, result: [:])
|
||||
case "notification.dismiss":
|
||||
dismisses.append((
|
||||
notificationIDs: info.notificationIDs ?? [],
|
||||
clientID: info.clientID
|
||||
))
|
||||
return try? Self.resultFrame(id: id, result: [:])
|
||||
case "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport":
|
||||
return try? Self.resultFrame(id: id, result: [:])
|
||||
default:
|
||||
@@ -234,7 +243,9 @@ private actor RoutingTransport: CmxByteTransport {
|
||||
id: parsed?["id"] as? String,
|
||||
surfaceID: params?["surface_id"] as? String,
|
||||
imageFormat: params?["image_format"] as? String,
|
||||
text: params?["text"] as? String
|
||||
text: params?["text"] as? String,
|
||||
notificationIDs: params?["notification_ids"] as? [String],
|
||||
clientID: params?["client_id"] as? String
|
||||
)
|
||||
Task { [router, weak self] in
|
||||
guard let response = await router.response(info) else {
|
||||
@@ -275,7 +286,12 @@ private actor RoutingTransport: CmxByteTransport {
|
||||
/// deterministic end-to-end exercise of submitComposer's routing over the real
|
||||
/// terminal.paste / terminal.paste_image RPC frames.
|
||||
@MainActor
|
||||
func makeRoutingConnectedStore(router: RoutingHostRouter) async throws -> MobileShellComposite {
|
||||
func makeRoutingConnectedStore(
|
||||
router: RoutingHostRouter,
|
||||
pendingDismissQueue: PendingNotificationDismissQueue = PendingNotificationDismissQueue(
|
||||
defaults: UserDefaults(suiteName: "routing-dismiss-\(UUID().uuidString)")!
|
||||
)
|
||||
) async throws -> MobileShellComposite {
|
||||
let runtime = RoutingTestRuntime(
|
||||
transportFactory: RoutingTransportFactory(router: router)
|
||||
)
|
||||
@@ -292,7 +308,8 @@ func makeRoutingConnectedStore(router: RoutingHostRouter) async throws -> Mobile
|
||||
name: "Routing Workspace",
|
||||
terminals: terminals
|
||||
),
|
||||
]
|
||||
],
|
||||
pendingDismissQueue: pendingDismissQueue
|
||||
)
|
||||
// 127.0.0.1 is a Stack-auth-trusted route, so authorized requests carry the
|
||||
// Stack token and do not throw insecureManualRoute before reaching the
|
||||
@@ -316,6 +333,7 @@ func makeRoutingConnectedStore(router: RoutingHostRouter) async throws -> Mobile
|
||||
ticket: ticket,
|
||||
allowsStackAuthFallback: true
|
||||
)
|
||||
store.foregroundMacDeviceID = "test-mac"
|
||||
return store
|
||||
}
|
||||
|
||||
@@ -347,4 +365,44 @@ func installFreshRemoteClient(on store: MobileShellComposite, router: RoutingHos
|
||||
ticket: ticket,
|
||||
allowsStackAuthFallback: true
|
||||
)
|
||||
store.foregroundMacDeviceID = "test-mac-2"
|
||||
}
|
||||
|
||||
/// Install a live read-only secondary client on `store`, backed by `router`.
|
||||
@MainActor
|
||||
func installSecondaryClient(
|
||||
on store: MobileShellComposite,
|
||||
macDeviceID: String,
|
||||
router: RoutingHostRouter
|
||||
) throws {
|
||||
let runtime = RoutingTestRuntime(
|
||||
transportFactory: RoutingTransportFactory(router: router)
|
||||
)
|
||||
let route = try CmxAttachRoute(
|
||||
id: "debug_loopback_\(macDeviceID)",
|
||||
kind: .debugLoopback,
|
||||
endpoint: .hostPort(host: "127.0.0.1", port: 56587)
|
||||
)
|
||||
let ticket = try CmxAttachTicket(
|
||||
workspaceID: RoutingHostRouter.workspaceID,
|
||||
terminalID: RoutingHostRouter.terminalA,
|
||||
macDeviceID: macDeviceID,
|
||||
macDisplayName: macDeviceID,
|
||||
routes: [route],
|
||||
expiresAt: Date().addingTimeInterval(3600)
|
||||
)
|
||||
let client = MobileCoreRPCClient(
|
||||
runtime: runtime,
|
||||
route: route,
|
||||
ticket: ticket,
|
||||
allowsStackAuthFallback: true
|
||||
)
|
||||
store.secondaryMacSubscriptions[macDeviceID] = SecondaryMacSubscription(
|
||||
macDeviceID: macDeviceID,
|
||||
client: client,
|
||||
route: route,
|
||||
ticket: ticket,
|
||||
supportedHostCapabilities: [],
|
||||
actionCapabilities: .none
|
||||
)
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileShellModel
|
||||
|
||||
actor DelayedTeamDeviceRegistry: DeviceRegistryRefreshing {
|
||||
private let teamIDProvider: @Sendable () async -> String?
|
||||
private let devicesByTeam: [String: [RegistryDevice]]
|
||||
private let blockedTeams: Set<String>
|
||||
private var startedTeams: Set<String> = []
|
||||
private var startWaiters: [String: [CheckedContinuation<Void, Never>]] = [:]
|
||||
private var blockers: [String: CheckedContinuation<Void, Never>] = [:]
|
||||
|
||||
init(
|
||||
teamIDProvider: @escaping @Sendable () async -> String?,
|
||||
devicesByTeam: [String: [RegistryDevice]],
|
||||
blockedTeams: Set<String>
|
||||
) {
|
||||
self.teamIDProvider = teamIDProvider
|
||||
self.devicesByTeam = devicesByTeam
|
||||
self.blockedTeams = blockedTeams
|
||||
}
|
||||
|
||||
func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? { nil }
|
||||
|
||||
func listDevices() async -> DeviceRegistryListOutcome {
|
||||
let key = await teamIDProvider() ?? ""
|
||||
markStarted(key)
|
||||
if blockedTeams.contains(key) {
|
||||
await withCheckedContinuation { continuation in
|
||||
blockers[key] = continuation
|
||||
}
|
||||
}
|
||||
return .ok(devicesByTeam[key] ?? [])
|
||||
}
|
||||
|
||||
func waitUntilLoadStarted(teamID: String?) async {
|
||||
let key = teamID ?? ""
|
||||
if startedTeams.contains(key) { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
startWaiters[key, default: []].append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release(teamID: String?) {
|
||||
let key = teamID ?? ""
|
||||
blockers.removeValue(forKey: key)?.resume()
|
||||
}
|
||||
|
||||
private func markStarted(_ key: String) {
|
||||
startedTeams.insert(key)
|
||||
let waiters = startWaiters.removeValue(forKey: key) ?? []
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import Foundation
|
||||
|
||||
actor DelayedTeamPairedMacStore: MobilePairedMacStoring {
|
||||
private let recordsByTeam: [String: [MobilePairedMac]]
|
||||
private let blockedTeams: Set<String>
|
||||
private var startedTeams: Set<String> = []
|
||||
private var startWaiters: [String: [CheckedContinuation<Void, Never>]] = [:]
|
||||
private var blockers: [String: CheckedContinuation<Void, Never>] = [:]
|
||||
|
||||
init(recordsByTeam: [String: [MobilePairedMac]], blockedTeams: Set<String>) {
|
||||
self.recordsByTeam = recordsByTeam
|
||||
self.blockedTeams = blockedTeams
|
||||
}
|
||||
|
||||
func upsert(
|
||||
macDeviceID: String,
|
||||
displayName: String?,
|
||||
routes: [CmxAttachRoute],
|
||||
markActive: Bool,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws {}
|
||||
|
||||
func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac] {
|
||||
let key = teamID ?? ""
|
||||
markStarted(key)
|
||||
if blockedTeams.contains(key) {
|
||||
await withCheckedContinuation { continuation in
|
||||
blockers[key] = continuation
|
||||
}
|
||||
}
|
||||
return recordsByTeam[key] ?? []
|
||||
}
|
||||
|
||||
func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? { nil }
|
||||
func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {}
|
||||
func clearActive(stackUserID: String?, teamID: String?) async throws {}
|
||||
func setCustomization(
|
||||
macDeviceID: String,
|
||||
customName: String?,
|
||||
customColor: String?,
|
||||
customIcon: String?,
|
||||
stackUserID: String?,
|
||||
teamID: String?,
|
||||
now: Date
|
||||
) async throws {}
|
||||
func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {}
|
||||
func removeAll() async throws {}
|
||||
|
||||
func waitUntilLoadStarted(teamID: String?) async {
|
||||
let key = teamID ?? ""
|
||||
if startedTeams.contains(key) { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
startWaiters[key, default: []].append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func release(teamID: String?) {
|
||||
let key = teamID ?? ""
|
||||
blockers.removeValue(forKey: key)?.resume()
|
||||
}
|
||||
|
||||
private func markStarted(_ key: String) {
|
||||
startedTeams.insert(key)
|
||||
let waiters = startWaiters.removeValue(forKey: key) ?? []
|
||||
for waiter in waiters { waiter.resume() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
/// In-memory backup double: records uploaded ops, counts fetches, and can be
|
||||
/// told to fail the first N fetches to exercise the retry path.
|
||||
actor FakeBackup: PairedMacBackingUp {
|
||||
private(set) var uploaded: [PairedMacBackupOp] = []
|
||||
private(set) var uploadedTeamIDs: [String?] = []
|
||||
private(set) var uploadedExpectedUserIDs: [String?] = []
|
||||
private(set) var fetchedExpectedUserIDs: [String?] = []
|
||||
private(set) var fetchCount = 0
|
||||
private let records: [PairedMacBackupRecord]
|
||||
private let deletedMacDeviceIDs: [String]
|
||||
private var failNextFetches: Int
|
||||
private var failNextUploads: Int
|
||||
|
||||
init(
|
||||
records: [PairedMacBackupRecord] = [],
|
||||
deletedMacDeviceIDs: [String] = [],
|
||||
failNextFetches: Int = 0,
|
||||
failNextUploads: Int = 0
|
||||
) {
|
||||
self.records = records
|
||||
self.deletedMacDeviceIDs = deletedMacDeviceIDs
|
||||
self.failNextFetches = failNextFetches
|
||||
self.failNextUploads = failNextUploads
|
||||
}
|
||||
|
||||
func upload(ops: [PairedMacBackupOp]) async -> Bool {
|
||||
uploaded.append(contentsOf: ops)
|
||||
uploadedTeamIDs.append(nil)
|
||||
uploadedExpectedUserIDs.append(nil)
|
||||
if failNextUploads > 0 {
|
||||
failNextUploads -= 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func upload(ops: [PairedMacBackupOp], teamID: String?) async -> Bool {
|
||||
await upload(ops: ops, teamID: teamID, expectedUserID: nil)
|
||||
}
|
||||
|
||||
func upload(ops: [PairedMacBackupOp], teamID: String?, expectedUserID: String?) async -> Bool {
|
||||
uploaded.append(contentsOf: ops)
|
||||
uploadedTeamIDs.append(teamID)
|
||||
uploadedExpectedUserIDs.append(expectedUserID)
|
||||
if failNextUploads > 0 {
|
||||
failNextUploads -= 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func fetchAll() async -> [PairedMacBackupRecord]? {
|
||||
await fetchSnapshot()?.records
|
||||
}
|
||||
|
||||
func fetchSnapshot() async -> PairedMacBackupSnapshot? {
|
||||
await fetchSnapshot(teamID: nil, expectedUserID: nil)
|
||||
}
|
||||
|
||||
func fetchSnapshot(teamID: String?, expectedUserID: String?) async -> PairedMacBackupSnapshot? {
|
||||
fetchedExpectedUserIDs.append(expectedUserID)
|
||||
fetchCount += 1
|
||||
if failNextFetches > 0 {
|
||||
failNextFetches -= 1
|
||||
return nil
|
||||
}
|
||||
return PairedMacBackupSnapshot(records: records, deletedMacDeviceIDs: deletedMacDeviceIDs)
|
||||
}
|
||||
|
||||
func uploadedOps() -> [PairedMacBackupOp] { uploaded }
|
||||
func uploadTeams() -> [String?] { uploadedTeamIDs }
|
||||
func uploadExpectedUsers() -> [String?] { uploadedExpectedUserIDs }
|
||||
func fetchExpectedUsers() -> [String?] { fetchedExpectedUserIDs }
|
||||
func fetches() -> Int { fetchCount }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import Foundation
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
/// Wraps a real inner store but blocks the first `upsert` until released, so a
|
||||
/// test can suspend a restore precisely inside its store write and prove the
|
||||
/// sign-out wipe is final.
|
||||
actor GatedUpsertStore: MobilePairedMacStoring {
|
||||
private let inner: MobilePairedMacStore
|
||||
private let failRemove: Bool
|
||||
private var enteredContinuation: CheckedContinuation<Void, Never>?
|
||||
private var entered = false
|
||||
private var releaseContinuation: CheckedContinuation<Void, Never>?
|
||||
private var released = false
|
||||
private var gateArmed = true
|
||||
|
||||
init(inner: MobilePairedMacStore, failRemove: Bool = false) {
|
||||
self.inner = inner
|
||||
self.failRemove = failRemove
|
||||
}
|
||||
|
||||
func waitUntilUpsertEntered() async {
|
||||
if entered { return }
|
||||
await withCheckedContinuation { enteredContinuation = $0 }
|
||||
}
|
||||
|
||||
func release() {
|
||||
released = true
|
||||
releaseContinuation?.resume()
|
||||
releaseContinuation = nil
|
||||
}
|
||||
|
||||
private func awaitRelease() async {
|
||||
if released { return }
|
||||
await withCheckedContinuation { releaseContinuation = $0 }
|
||||
}
|
||||
|
||||
func upsert(
|
||||
macDeviceID: String, displayName: String?, routes: [CmxAttachRoute],
|
||||
markActive: Bool, stackUserID: String?, teamID: String?, now: Date
|
||||
) async throws {
|
||||
if gateArmed {
|
||||
gateArmed = false
|
||||
entered = true
|
||||
enteredContinuation?.resume()
|
||||
enteredContinuation = nil
|
||||
await awaitRelease()
|
||||
}
|
||||
try await inner.upsert(
|
||||
macDeviceID: macDeviceID, displayName: displayName, routes: routes,
|
||||
markActive: markActive, stackUserID: stackUserID, teamID: teamID, now: now)
|
||||
}
|
||||
|
||||
func loadAll(stackUserID: String?, teamID: String?) async throws -> [MobilePairedMac] {
|
||||
try await inner.loadAll(stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
func activeMac(stackUserID: String?, teamID: String?) async throws -> MobilePairedMac? {
|
||||
try await inner.activeMac(stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
func setActive(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {
|
||||
try await inner.setActive(macDeviceID: macDeviceID, stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
func clearActive(stackUserID: String?, teamID: String?) async throws {
|
||||
try await inner.clearActive(stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
func setCustomization(
|
||||
macDeviceID: String, customName: String?, customColor: String?,
|
||||
customIcon: String?, stackUserID: String?, teamID: String?, now: Date
|
||||
) async throws {
|
||||
try await inner.setCustomization(
|
||||
macDeviceID: macDeviceID, customName: customName, customColor: customColor,
|
||||
customIcon: customIcon, stackUserID: stackUserID, teamID: teamID, now: now)
|
||||
}
|
||||
|
||||
func remove(macDeviceID: String, stackUserID: String?, teamID: String?) async throws {
|
||||
if failRemove { throw NSError(domain: "GatedUpsertStore", code: 1) }
|
||||
try await inner.remove(macDeviceID: macDeviceID, stackUserID: stackUserID, teamID: teamID)
|
||||
}
|
||||
|
||||
func removeAll() async throws {
|
||||
try await inner.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
struct MacBuildChannelTests {
|
||||
@Test func devTagWinsAndIsShown() {
|
||||
// A tagged reload.sh build sets CMUX_TAG; any non-"default" tag is a DEV
|
||||
// build and the tag is what's worth showing — regardless of bundle id.
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.debug.teams", tag: "teams") == "DEV · teams")
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app", tag: "my-tag") == "DEV · my-tag")
|
||||
}
|
||||
|
||||
@Test func channelFromBundleComponentWhenNoDevTag() {
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app", tag: "default") == "Stable")
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.nightly", tag: "default") == "Nightly")
|
||||
// Tagged channel builds append a further .slug — match the COMPONENT, not a suffix.
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.nightly.my-feature", tag: "default") == "Nightly")
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.staging.feat", tag: nil) == "Staging")
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.debug", tag: "default") == "DEV")
|
||||
}
|
||||
|
||||
@Test func handlesFutureReleaseCandidateChannel() {
|
||||
// The RC desktop build (com.cmuxterm.app.rc) is handled ahead of time.
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.rc", tag: "default") == "RC")
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.rc.candidate1", tag: nil) == "RC")
|
||||
}
|
||||
|
||||
@Test func nilWhenNotIdentifiable() {
|
||||
#expect(MacBuildChannel().label(bundleID: nil, tag: "default") == nil)
|
||||
#expect(MacBuildChannel().label(bundleID: nil, tag: nil) == nil)
|
||||
#expect(MacBuildChannel().label(bundleID: "com.example.other", tag: "default") == nil)
|
||||
// Unknown future channel component is not guessed at.
|
||||
#expect(MacBuildChannel().label(bundleID: "com.cmuxterm.app.beta", tag: "default") == nil)
|
||||
}
|
||||
}
|
||||
+270
-2
@@ -1,4 +1,5 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileRPC
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
@@ -45,7 +46,7 @@ import Testing
|
||||
store.connectPreviewHost()
|
||||
// Group sections are account-scoped: the previous account's group
|
||||
// names must not survive sign-out into the next session.
|
||||
store.workspaceGroups = [
|
||||
store.setWorkspacesForTesting(store.workspaces, groups: [
|
||||
MobileWorkspaceGroupPreview(
|
||||
id: "group-1",
|
||||
name: "previous account group",
|
||||
@@ -53,7 +54,7 @@ import Testing
|
||||
isPinned: false,
|
||||
anchorWorkspaceID: "workspace-main"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
store.signOut()
|
||||
|
||||
@@ -64,6 +65,74 @@ import Testing
|
||||
#expect(store.workspaceGroups.isEmpty)
|
||||
}
|
||||
|
||||
@Test func currentTeamDidChangeKeepsForegroundWorkspacesLive() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
store.pairingCode = "debug"
|
||||
store.connectPreviewHost()
|
||||
store.setWorkspacesForTesting([
|
||||
MobileWorkspacePreview(id: "ws-foreground", name: "Live", terminals: []),
|
||||
])
|
||||
#expect(store.workspaces.map(\.id.rawValue) == ["ws-foreground"])
|
||||
let connectionBefore = store.connectionState
|
||||
|
||||
// A team switch must re-scope lists lazily but NEVER drop the live
|
||||
// foreground terminal session.
|
||||
store.currentTeamDidChange()
|
||||
|
||||
#expect(store.workspaces.map(\.id.rawValue) == ["ws-foreground"])
|
||||
#expect(store.connectionState == connectionBefore)
|
||||
// Team-scoped caches are cleared so they lazily repopulate for the new team.
|
||||
#expect(store.pairedMacs.isEmpty)
|
||||
#expect(store.registryDevices.isEmpty)
|
||||
}
|
||||
|
||||
@Test func staleTeamLoadsDoNotClearCurrentTeamLists() async throws {
|
||||
let team = MutableTeamID("team-a")
|
||||
let pairedStore = DelayedTeamPairedMacStore(
|
||||
recordsByTeam: [
|
||||
"team-a": [try Self.pairedMac(id: "mac-a", teamID: "team-a")],
|
||||
"team-b": [try Self.pairedMac(id: "mac-b", teamID: "team-b")],
|
||||
],
|
||||
blockedTeams: ["team-a"]
|
||||
)
|
||||
let registry = DelayedTeamDeviceRegistry(
|
||||
teamIDProvider: { await team.value },
|
||||
devicesByTeam: [
|
||||
"team-a": [Self.registryDevice(id: "device-a")],
|
||||
"team-b": [Self.registryDevice(id: "device-b")],
|
||||
],
|
||||
blockedTeams: ["team-a"]
|
||||
)
|
||||
let store = MobileShellComposite(
|
||||
isSignedIn: true,
|
||||
pairedMacStore: pairedStore,
|
||||
deviceRegistry: registry,
|
||||
identityProvider: StaticIdentityProvider(userID: "user-1"),
|
||||
teamIDProvider: { await team.value }
|
||||
)
|
||||
|
||||
let oldPairedLoad = Task { await store.loadPairedMacs() }
|
||||
let oldRegistryLoad = Task { await store.loadRegistryDevices() }
|
||||
await pairedStore.waitUntilLoadStarted(teamID: "team-a")
|
||||
await registry.waitUntilLoadStarted(teamID: "team-a")
|
||||
|
||||
await team.set("team-b")
|
||||
store.currentTeamDidChange()
|
||||
await store.loadPairedMacs()
|
||||
await store.loadRegistryDevices()
|
||||
#expect(store.pairedMacs.map(\.macDeviceID) == ["mac-b"])
|
||||
#expect(store.registryDevices.map(\.deviceId) == ["device-b"])
|
||||
|
||||
await pairedStore.release(teamID: "team-a")
|
||||
await registry.release(teamID: "team-a")
|
||||
_ = await oldPairedLoad.value
|
||||
_ = await oldRegistryLoad.value
|
||||
|
||||
#expect(store.pairedMacs.map(\.macDeviceID) == ["mac-b"])
|
||||
#expect(store.registryDevices.map(\.deviceId) == ["device-b"])
|
||||
}
|
||||
|
||||
@Test func createWorkspaceSelectsNewWorkspaceAndTerminal() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
@@ -77,6 +146,29 @@ import Testing
|
||||
#expect(store.selectedTerminalID?.rawValue == "workspace-3-terminal-1")
|
||||
}
|
||||
|
||||
private static func pairedMac(id: String, teamID: String) throws -> MobilePairedMac {
|
||||
MobilePairedMac(
|
||||
macDeviceID: id,
|
||||
displayName: id,
|
||||
routes: [try CmxAttachRoute(id: "manual", kind: .tailscale, endpoint: .hostPort(host: "10.0.0.1", port: 22))],
|
||||
createdAt: Date(timeIntervalSince1970: 1),
|
||||
lastSeenAt: Date(timeIntervalSince1970: 2),
|
||||
isActive: false,
|
||||
stackUserID: "user-1",
|
||||
teamID: teamID
|
||||
)
|
||||
}
|
||||
|
||||
private static func registryDevice(id: String) -> RegistryDevice {
|
||||
RegistryDevice(
|
||||
deviceId: id,
|
||||
platform: "mac",
|
||||
displayName: id,
|
||||
lastSeenAt: Date(timeIntervalSince1970: 2),
|
||||
instances: []
|
||||
)
|
||||
}
|
||||
|
||||
@Test func createTerminalAddsTerminalToSelectedWorkspace() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
@@ -183,6 +275,182 @@ import Testing
|
||||
#expect(store.selectedTerminalID?.rawValue == "terminal-notes")
|
||||
}
|
||||
|
||||
@Test func aggregationRowIDScopingPreservesCurrentSelection() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
let foregroundWorkspace = MobileWorkspacePreview(
|
||||
id: "w-foreground",
|
||||
macDeviceID: "mac-a",
|
||||
name: "Foreground",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-foreground", name: "fg")]
|
||||
)
|
||||
let selectedWorkspace = MobileWorkspacePreview(
|
||||
id: "w-selected",
|
||||
macDeviceID: "mac-a",
|
||||
name: "Selected",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-selected", name: "selected")]
|
||||
)
|
||||
let secondaryWorkspace = MobileWorkspacePreview(
|
||||
id: "w-secondary",
|
||||
macDeviceID: "mac-b",
|
||||
name: "Secondary",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-secondary", name: "secondary")]
|
||||
)
|
||||
store.setWorkspaceStatesForTesting([
|
||||
"mac-a": MacWorkspaceState(
|
||||
macDeviceID: "mac-a",
|
||||
workspaces: [foregroundWorkspace, selectedWorkspace],
|
||||
status: .connected
|
||||
),
|
||||
], foregroundMacDeviceID: "mac-a")
|
||||
store.selectedWorkspaceID = "w-selected"
|
||||
store.selectedTerminalID = "terminal-selected"
|
||||
|
||||
store.setWorkspaceStatesForTesting([
|
||||
"mac-a": MacWorkspaceState(
|
||||
macDeviceID: "mac-a",
|
||||
workspaces: [foregroundWorkspace, selectedWorkspace],
|
||||
status: .connected
|
||||
),
|
||||
"mac-b": MacWorkspaceState(
|
||||
macDeviceID: "mac-b",
|
||||
workspaces: [secondaryWorkspace],
|
||||
status: .connected
|
||||
),
|
||||
], foregroundMacDeviceID: "mac-a")
|
||||
|
||||
#expect(store.selectedWorkspace?.name == "Selected")
|
||||
#expect(store.selectedWorkspace?.rpcWorkspaceID.rawValue == "w-selected")
|
||||
#expect(store.selectedWorkspace?.macDeviceID == "mac-a")
|
||||
#expect(store.selectedTerminalID?.rawValue == "terminal-selected")
|
||||
}
|
||||
|
||||
@Test func anonymousForegroundRowsDoNotExposeAggregateSentinel() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
let anonymousWorkspace = MobileWorkspacePreview(
|
||||
id: "w-anonymous",
|
||||
name: "Manual",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-anonymous", name: "manual")]
|
||||
)
|
||||
let secondaryWorkspace = MobileWorkspacePreview(
|
||||
id: "w-secondary",
|
||||
macDeviceID: "mac-b",
|
||||
name: "Secondary",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-secondary", name: "secondary")]
|
||||
)
|
||||
|
||||
store.setWorkspaceStatesForTesting([
|
||||
MobileShellComposite.foregroundAnonymousKey: MacWorkspaceState(
|
||||
macDeviceID: MobileShellComposite.foregroundAnonymousKey,
|
||||
workspaces: [anonymousWorkspace],
|
||||
status: .connected
|
||||
),
|
||||
"mac-b": MacWorkspaceState(
|
||||
macDeviceID: "mac-b",
|
||||
workspaces: [secondaryWorkspace],
|
||||
status: .connected
|
||||
),
|
||||
], foregroundMacDeviceID: nil)
|
||||
|
||||
let foreground = store.workspaces.first { $0.rpcWorkspaceID.rawValue == "w-anonymous" }
|
||||
#expect(foreground?.macDeviceID == nil)
|
||||
#expect(foreground?.remoteWorkspaceID?.rawValue == "w-anonymous")
|
||||
}
|
||||
|
||||
@Test func deeplinkWorkspaceResolutionUsesMacOwnerWhenWorkspaceIDsCollide() throws {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
let workspaceA = MobileWorkspacePreview(
|
||||
id: "shared",
|
||||
macDeviceID: "mac-a",
|
||||
name: "Mac A",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-shared", name: "a")]
|
||||
)
|
||||
let workspaceB = MobileWorkspacePreview(
|
||||
id: "shared",
|
||||
macDeviceID: "mac-b",
|
||||
name: "Mac B",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-shared", name: "b")]
|
||||
)
|
||||
store.setWorkspaceStatesForTesting([
|
||||
"mac-a": MacWorkspaceState(
|
||||
macDeviceID: "mac-a",
|
||||
workspaces: [workspaceA],
|
||||
status: .connected
|
||||
),
|
||||
"mac-b": MacWorkspaceState(
|
||||
macDeviceID: "mac-b",
|
||||
workspaces: [workspaceB],
|
||||
status: .connected
|
||||
),
|
||||
], foregroundMacDeviceID: "mac-a")
|
||||
|
||||
let resolvedWorkspaceID = try #require(store.workspaceID(
|
||||
matchingRemoteWorkspaceID: "shared",
|
||||
macDeviceID: "mac-b"
|
||||
))
|
||||
let resolvedSurfaceOwnerID = try #require(store.workspaceID(
|
||||
containingSurfaceID: "terminal-shared",
|
||||
macDeviceID: "mac-b"
|
||||
))
|
||||
|
||||
let workspace = try #require(store.workspaces.first { $0.id == resolvedWorkspaceID })
|
||||
#expect(workspace.macDeviceID == "mac-b")
|
||||
#expect(resolvedSurfaceOwnerID == resolvedWorkspaceID)
|
||||
#expect(store.workspaceID(matchingRemoteWorkspaceID: "shared", macDeviceID: "missing") == nil)
|
||||
}
|
||||
|
||||
@Test func foregroundNotificationSuppressionRequiresExplicitSelection() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
let workspace = MobileWorkspacePreview(
|
||||
id: "row-a",
|
||||
macDeviceID: "mac-a",
|
||||
name: "First",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-a", name: "a")]
|
||||
)
|
||||
store.setWorkspaceStatesForTesting([
|
||||
"mac-a": MacWorkspaceState(
|
||||
macDeviceID: "mac-a",
|
||||
workspaces: [workspace],
|
||||
status: .connected
|
||||
),
|
||||
], foregroundMacDeviceID: "mac-a")
|
||||
store.selectedWorkspaceID = nil
|
||||
|
||||
#expect(store.selectedWorkspace?.id.rawValue == "row-a")
|
||||
#expect(!store.selectedWorkspaceMatches(remoteWorkspaceID: "row-a", macDeviceID: "mac-a"))
|
||||
|
||||
store.selectedWorkspaceID = "row-a"
|
||||
#expect(store.selectedWorkspaceMatches(remoteWorkspaceID: "row-a", macDeviceID: "mac-a"))
|
||||
}
|
||||
|
||||
@Test func secondaryUnavailableDowngradeKeepsRowsVisibleButInactive() {
|
||||
let store = MobileShellComposite.preview()
|
||||
store.signIn()
|
||||
let workspace = MobileWorkspacePreview(
|
||||
id: "secondary-row",
|
||||
macDeviceID: "mac-b",
|
||||
name: "Secondary",
|
||||
terminals: [MobileTerminalPreview(id: "terminal-b", name: "b")]
|
||||
)
|
||||
store.setWorkspaceStatesForTesting([
|
||||
"mac-b": MacWorkspaceState(
|
||||
macDeviceID: "mac-b",
|
||||
displayName: "Mac B",
|
||||
workspaces: [workspace],
|
||||
status: .connected
|
||||
),
|
||||
], foregroundMacDeviceID: nil)
|
||||
|
||||
store.markSecondaryMacUnavailableForTesting("mac-b")
|
||||
|
||||
let downgraded = store.workspaces.first { $0.rpcWorkspaceID.rawValue == "secondary-row" }
|
||||
#expect(downgraded?.macConnectionStatus == .unavailable)
|
||||
#expect(downgraded?.name == "Secondary")
|
||||
}
|
||||
|
||||
@Test func activeMacReconnectRouteSkipsUnsupportedLoopbackRoute() throws {
|
||||
let loopback = try hostPortRoute(
|
||||
kind: .debugLoopback,
|
||||
|
||||
+55
-1
@@ -57,9 +57,10 @@ import UserNotifications
|
||||
pendingDismissQueue: queue
|
||||
)
|
||||
|
||||
await store.dismissNotification(ids: [" n-1 ", "", "n-2"])
|
||||
await store.dismissNotification(ids: [" n-1 ", "", "n-2"], macDeviceID: " mac-a ")
|
||||
|
||||
#expect(queue.pendingIDs == ["n-1", "n-2"])
|
||||
#expect(queue.pendingDismisses.map(\.macDeviceID) == ["mac-a", "mac-a"])
|
||||
}
|
||||
|
||||
@Test func dismissWithNoUsableIDsLeavesOutboxEmpty() async {
|
||||
@@ -76,6 +77,59 @@ import UserNotifications
|
||||
#expect(queue.pendingIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test func dismissRoutesToOwningSecondaryMac() async throws {
|
||||
let foregroundRouter = RoutingHostRouter()
|
||||
let secondaryRouter = RoutingHostRouter()
|
||||
let store = try await makeRoutingConnectedStore(router: foregroundRouter)
|
||||
try installSecondaryClient(on: store, macDeviceID: "mac-secondary", router: secondaryRouter)
|
||||
|
||||
await store.dismissNotification(ids: [" n-secondary "], macDeviceID: "mac-secondary")
|
||||
|
||||
let foregroundDismisses = await foregroundRouter.recordedDismisses()
|
||||
let secondaryDismisses = await secondaryRouter.recordedDismisses()
|
||||
#expect(foregroundDismisses.isEmpty)
|
||||
#expect(secondaryDismisses.map(\.notificationIDs) == [["n-secondary"]])
|
||||
#expect(store.pendingDismissQueue.pendingDismisses.isEmpty)
|
||||
}
|
||||
|
||||
@Test func secondaryFlushDrainsOnlyThatMacsQueuedDismisses() async throws {
|
||||
let foregroundRouter = RoutingHostRouter()
|
||||
let secondaryRouter = RoutingHostRouter()
|
||||
let queue = PendingNotificationDismissQueue(
|
||||
defaults: UserDefaults(suiteName: "dismiss-queue-\(UUID().uuidString)")!
|
||||
)
|
||||
let store = try await makeRoutingConnectedStore(
|
||||
router: foregroundRouter,
|
||||
pendingDismissQueue: queue
|
||||
)
|
||||
queue.enqueue([
|
||||
(id: "n-secondary", macDeviceID: "mac-secondary"),
|
||||
(id: "n-other", macDeviceID: "mac-other"),
|
||||
])
|
||||
try installSecondaryClient(on: store, macDeviceID: "mac-secondary", router: secondaryRouter)
|
||||
|
||||
await store.flushPendingNotificationDismisses(macDeviceID: "mac-secondary")
|
||||
|
||||
let foregroundDismisses = await foregroundRouter.recordedDismisses()
|
||||
let secondaryDismisses = await secondaryRouter.recordedDismisses()
|
||||
#expect(foregroundDismisses.isEmpty)
|
||||
#expect(secondaryDismisses.map(\.notificationIDs) == [["n-secondary"]])
|
||||
#expect(queue.pendingDismisses.map(\.id) == ["n-other"])
|
||||
#expect(queue.pendingDismisses.map(\.macDeviceID) == ["mac-other"])
|
||||
}
|
||||
|
||||
@Test func dismissForUnavailableMacStaysQueuedAndDoesNotHitForeground() async throws {
|
||||
let foregroundRouter = RoutingHostRouter()
|
||||
let store = try await makeRoutingConnectedStore(router: foregroundRouter)
|
||||
|
||||
await store.dismissNotification(ids: ["n-missing"], macDeviceID: "mac-missing")
|
||||
|
||||
let foregroundDismisses = await foregroundRouter.recordedDismisses()
|
||||
#expect(foregroundDismisses.isEmpty)
|
||||
#expect(store.pendingDismissQueue.pendingDismisses.map(\.id) == ["n-missing"])
|
||||
#expect(store.pendingDismissQueue.pendingDismisses.map(\.macDeviceID) == ["mac-missing"])
|
||||
}
|
||||
|
||||
@Test func setsBadgeToAuthoritativeTotal() {
|
||||
let clearer = RecordingDeliveredNotificationClearer()
|
||||
let store = makeStore(clearer: clearer)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
/// Backup double whose records can change mid-session, to model a Mac
|
||||
/// republishing a fresh route after the once-per-launch restore already ran.
|
||||
actor MutableBackup: PairedMacBackingUp {
|
||||
private var records: [PairedMacBackupRecord]
|
||||
private(set) var fetchCount = 0
|
||||
|
||||
init(records: [PairedMacBackupRecord]) {
|
||||
self.records = records
|
||||
}
|
||||
|
||||
func setRecords(_ records: [PairedMacBackupRecord]) { self.records = records }
|
||||
func upload(ops: [PairedMacBackupOp]) async -> Bool { true }
|
||||
func fetchAll() async -> [PairedMacBackupRecord]? {
|
||||
await fetchSnapshot()?.records
|
||||
}
|
||||
func fetchSnapshot() async -> PairedMacBackupSnapshot? {
|
||||
fetchCount += 1
|
||||
return PairedMacBackupSnapshot(records: records)
|
||||
}
|
||||
func fetches() -> Int { fetchCount }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Mutable team holder so a test can simulate a team switch mid-session.
|
||||
actor MutableTeam {
|
||||
var value: String
|
||||
init(_ value: String) { self.value = value }
|
||||
func set(_ value: String) { self.value = value }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// Mutable team holder so tests can simulate a Stack team switch mid-session.
|
||||
actor MutableTeamID {
|
||||
var value: String?
|
||||
|
||||
init(_ value: String?) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func set(_ value: String?) {
|
||||
self.value = value
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+19
@@ -14,6 +14,16 @@ import Testing
|
||||
queue.enqueue([" n-1 ", "", " ", "n-2"])
|
||||
|
||||
#expect(queue.pendingIDs == ["n-1", "n-2"])
|
||||
#expect(queue.pendingDismisses.map(\.macDeviceID) == [nil, nil])
|
||||
}
|
||||
|
||||
@Test func enqueueCarriesOwningMacID() {
|
||||
let queue = PendingNotificationDismissQueue(defaults: makeDefaults())
|
||||
|
||||
queue.enqueue([" n-1 ", "n-2"], macDeviceID: " mac-a ")
|
||||
|
||||
#expect(queue.pendingIDs == ["n-1", "n-2"])
|
||||
#expect(queue.pendingDismisses.map(\.macDeviceID) == ["mac-a", "mac-a"])
|
||||
}
|
||||
|
||||
@Test func enqueueKeepsDuplicatesOnceAndPreservesOrder() {
|
||||
@@ -67,4 +77,13 @@ import Testing
|
||||
compositeSide.remove(["n-1"])
|
||||
#expect(coordinatorSide.pendingIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test func readsLegacyBareIDArrayAsForegroundDismisses() {
|
||||
let defaults = makeDefaults()
|
||||
defaults.set(["legacy-1"], forKey: "cmux.notifications.pendingMacDismissIds")
|
||||
let queue = PendingNotificationDismissQueue(defaults: defaults)
|
||||
|
||||
#expect(queue.pendingIDs == ["legacy-1"])
|
||||
#expect(queue.pendingDismisses.map(\.macDeviceID) == [nil])
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
/// The presence/backup service URL resolution drives which (possibly per-developer
|
||||
/// isolated) worker a build talks to. Precedence: env → UserDefaults → Info.plist
|
||||
/// → Debug default. The Info.plist path is what lets a tapped iOS device build be
|
||||
/// pointed at a per-dev worker (see workers/presence/scripts/deploy-dev.sh).
|
||||
struct PresenceServiceURLResolutionTests {
|
||||
private func emptyDefaults() -> UserDefaults {
|
||||
let suite = "presence-url-test-\(UUID().uuidString)"
|
||||
let d = UserDefaults(suiteName: suite)!
|
||||
d.removePersistentDomain(forName: suite)
|
||||
return d
|
||||
}
|
||||
|
||||
@Test func envOverrideWinsOverEverything() {
|
||||
let url = PresenceClient.resolvedServiceBaseURL(
|
||||
environment: [PresenceClient.serviceURLEnvKey: "https://env.example"],
|
||||
defaults: emptyDefaults(),
|
||||
infoPlistValue: "https://plist.example",
|
||||
isDebugBuild: true
|
||||
)
|
||||
#expect(url == "https://env.example")
|
||||
}
|
||||
|
||||
@Test func defaultsOverrideWinsOverInfoPlist() {
|
||||
let d = emptyDefaults()
|
||||
d.set("https://defaults.example", forKey: PresenceClient.serviceURLDefaultsKey)
|
||||
let url = PresenceClient.resolvedServiceBaseURL(
|
||||
environment: [:],
|
||||
defaults: d,
|
||||
infoPlistValue: "https://plist.example",
|
||||
isDebugBuild: true
|
||||
)
|
||||
#expect(url == "https://defaults.example")
|
||||
}
|
||||
|
||||
@Test func infoPlistUsedWhenNoEnvOrDefaults() {
|
||||
let url = PresenceClient.resolvedServiceBaseURL(
|
||||
environment: [:],
|
||||
defaults: emptyDefaults(),
|
||||
infoPlistValue: "https://cmux-presence-dev-alice.acct.workers.dev",
|
||||
isDebugBuild: true
|
||||
)
|
||||
#expect(url == "https://cmux-presence-dev-alice.acct.workers.dev")
|
||||
}
|
||||
|
||||
@Test func fallsBackToBuildDefault() {
|
||||
// Debug -> dev worker; Release -> production worker (so a stable iOS app
|
||||
// subscribes to the same presence service stable Macs heartbeat to).
|
||||
#expect(PresenceClient.resolvedServiceBaseURL(
|
||||
environment: [:], defaults: emptyDefaults(), infoPlistValue: nil, isDebugBuild: true
|
||||
) == PresenceClient.debugDefaultServiceURL)
|
||||
#expect(PresenceClient.resolvedServiceBaseURL(
|
||||
environment: [:], defaults: emptyDefaults(), infoPlistValue: nil, isDebugBuild: false
|
||||
) == PresenceClient.productionServiceURL)
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import CMUXMobileCore
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
/// A restored/published Mac advertises both a `debug_loopback` route
|
||||
/// (`127.0.0.1`, priority 0) and a `tailscale` route. On a physical phone the
|
||||
/// loopback route names the phone itself and can never reach the Mac, so route
|
||||
/// selection must prefer the real route there — otherwise tapping a saved Mac
|
||||
/// dials the phone's own loopback and silently fails to connect.
|
||||
@MainActor
|
||||
@Suite struct ReconnectRouteSelectionTests {
|
||||
private func loopback(_ port: Int = 50906) throws -> CmxAttachRoute {
|
||||
try CmxAttachRoute(
|
||||
id: "debug_loopback",
|
||||
kind: .debugLoopback,
|
||||
endpoint: .hostPort(host: "127.0.0.1", port: port),
|
||||
priority: 0
|
||||
)
|
||||
}
|
||||
|
||||
private func tailscale(_ port: Int = 50906) throws -> CmxAttachRoute {
|
||||
try CmxAttachRoute(
|
||||
id: "tailscale",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "100.82.214.112", port: port),
|
||||
priority: 10
|
||||
)
|
||||
}
|
||||
|
||||
@Test func physicalDevicePrefersRealRouteOverLowerPriorityLoopback() throws {
|
||||
let pick = MobileShellComposite.firstReconnectHostPortRoute(
|
||||
[try loopback(), try tailscale()],
|
||||
supportedKinds: [.debugLoopback, .tailscale],
|
||||
preferNonLoopback: true
|
||||
)
|
||||
#expect(pick?.0 == "100.82.214.112") // tailscale, not the phone's 127.0.0.1
|
||||
}
|
||||
|
||||
@Test func physicalDeviceFallsBackToLoopbackWhenItIsTheOnlyRoute() throws {
|
||||
// The on-device XCUITest mock host serves a real listener on 127.0.0.1.
|
||||
let pick = MobileShellComposite.firstReconnectHostPortRoute(
|
||||
[try loopback()],
|
||||
supportedKinds: [.debugLoopback, .tailscale],
|
||||
preferNonLoopback: true
|
||||
)
|
||||
#expect(pick?.0 == "127.0.0.1")
|
||||
}
|
||||
|
||||
@Test func simulatorKeepsLoopbackPriorityOrder() throws {
|
||||
// On the simulator 127.0.0.1 IS the host Mac, so priority order stands.
|
||||
let pick = MobileShellComposite.firstReconnectHostPortRoute(
|
||||
[try loopback(), try tailscale()],
|
||||
supportedKinds: [.debugLoopback, .tailscale],
|
||||
preferNonLoopback: false
|
||||
)
|
||||
#expect(pick?.0 == "127.0.0.1")
|
||||
}
|
||||
|
||||
private func magicDNS(_ port: Int = 50906) throws -> CmxAttachRoute {
|
||||
// A MagicDNS hostname route, advertised BEFORE the IP route by priority.
|
||||
try CmxAttachRoute(
|
||||
id: "tailscale",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "lawrences-macbook-pro-2.tail137216.ts.net", port: port),
|
||||
priority: 5
|
||||
)
|
||||
}
|
||||
|
||||
@Test func physicalDevicePrefersIPLiteralOverMagicDNSHostname() throws {
|
||||
// The exact dogfood failure: a Mac advertises loopback, a MagicDNS
|
||||
// hostname (higher priority), and the raw tailscale IP. MagicDNS doesn't
|
||||
// resolve on the phone, so dialing the hostname times out; selection must
|
||||
// pick the IP literal so the secondary fetch / reconnect actually connects.
|
||||
let ip = try CmxAttachRoute(
|
||||
id: "tailscale_2",
|
||||
kind: .tailscale,
|
||||
endpoint: .hostPort(host: "100.82.214.112", port: 50922),
|
||||
priority: 10
|
||||
)
|
||||
let pick = MobileShellComposite.firstReconnectHostPortRoute(
|
||||
[try loopback(50922), try magicDNS(50922), ip],
|
||||
supportedKinds: [.debugLoopback, .tailscale],
|
||||
preferNonLoopback: true
|
||||
)
|
||||
#expect(pick?.0 == "100.82.214.112")
|
||||
}
|
||||
|
||||
@Test func magicDNSHostnameStillUsedWhenNoIPRouteExists() throws {
|
||||
// If the only non-loopback route is a hostname, still prefer it over
|
||||
// loopback on device (better than dialing the phone's own 127.0.0.1).
|
||||
let pick = MobileShellComposite.firstReconnectHostPortRoute(
|
||||
[try loopback(50922), try magicDNS(50922)],
|
||||
supportedKinds: [.debugLoopback, .tailscale],
|
||||
preferNonLoopback: true
|
||||
)
|
||||
#expect(pick?.0 == "lawrences-macbook-pro-2.tail137216.ts.net")
|
||||
}
|
||||
|
||||
@Test func ipLiteralHostClassification() {
|
||||
#expect(MobileShellComposite.isIPLiteralHost("100.82.214.112"))
|
||||
#expect(MobileShellComposite.isIPLiteralHost("127.0.0.1"))
|
||||
#expect(MobileShellComposite.isIPLiteralHost("fd7a:115c:a1e0::4b36:d670"))
|
||||
#expect(!MobileShellComposite.isIPLiteralHost("lawrences-macbook-pro-2.tail137216.ts.net"))
|
||||
#expect(!MobileShellComposite.isIPLiteralHost("example.com"))
|
||||
#expect(!MobileShellComposite.isIPLiteralHost("100.82.214")) // too few octets
|
||||
#expect(!MobileShellComposite.isIPLiteralHost("256.1.1.1")) // out of range
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import CmuxMobileShellModel
|
||||
|
||||
@MainActor
|
||||
final class StaticIdentityProvider: MobileIdentityProviding {
|
||||
var currentUserID: String?
|
||||
|
||||
init(userID: String?) {
|
||||
self.currentUserID = userID
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CmuxMobileShell
|
||||
|
||||
@Suite struct TeamScopedPairedMacStoreTests {
|
||||
private func makeInnerStore() throws -> (MobilePairedMacStore, URL) {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let store = try MobilePairedMacStore(
|
||||
databaseURL: directory.appendingPathComponent("paired-macs.sqlite3")
|
||||
)
|
||||
return (store, directory)
|
||||
}
|
||||
|
||||
private func route(_ host: String) throws -> CmxAttachRoute {
|
||||
try CmxAttachRoute(id: "manual", kind: .tailscale, endpoint: .hostPort(host: host, port: 22))
|
||||
}
|
||||
|
||||
@Test func scopesConvenienceCallsByCurrentTeamWithoutBackup() async throws {
|
||||
let (inner, directory) = try makeInnerStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let team = MutableTeamID("team-a")
|
||||
let store = TeamScopedPairedMacStore(inner: inner, teamIDProvider: { await team.value })
|
||||
|
||||
try await store.upsert(
|
||||
macDeviceID: "mac-a",
|
||||
displayName: "A",
|
||||
routes: [try route("10.0.0.1")],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
now: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
|
||||
#expect(try await inner.loadAll(stackUserID: "user-1").first?.teamID == "team-a")
|
||||
#expect(try await store.loadAll(stackUserID: "user-1").map(\.macDeviceID) == ["mac-a"])
|
||||
|
||||
await team.set("team-b")
|
||||
#expect(try await store.loadAll(stackUserID: "user-1").isEmpty)
|
||||
#expect(try await store.activeMac(stackUserID: "user-1") == nil)
|
||||
|
||||
try await store.upsert(
|
||||
macDeviceID: "mac-b",
|
||||
displayName: "B",
|
||||
routes: [try route("10.0.0.2")],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
now: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
#expect(try await store.loadAll(stackUserID: "user-1").map(\.macDeviceID) == ["mac-b"])
|
||||
#expect(try await inner.activeMac(stackUserID: "user-1", teamID: "team-b")?.macDeviceID == "mac-b")
|
||||
#expect(try await inner.activeMac(stackUserID: "user-1", teamID: "team-a")?.macDeviceID == "mac-a")
|
||||
|
||||
await team.set("team-a")
|
||||
#expect(try await store.loadAll(stackUserID: "user-1").map(\.macDeviceID) == ["mac-a"])
|
||||
}
|
||||
|
||||
@Test func customizationPreservesVisibleLegacyRowScope() async throws {
|
||||
let (inner, directory) = try makeInnerStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = TeamScopedPairedMacStore(
|
||||
inner: inner,
|
||||
teamIDProvider: { "team-a" }
|
||||
)
|
||||
|
||||
try await inner.upsert(
|
||||
macDeviceID: "mac-legacy",
|
||||
displayName: "Legacy",
|
||||
routes: [try route("10.0.0.1")],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
|
||||
try await store.setCustomization(
|
||||
macDeviceID: "mac-legacy",
|
||||
customName: "Studio",
|
||||
customColor: "palette:4",
|
||||
customIcon: "terminal",
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
let visible = try await store.loadAll(stackUserID: "user-1").first { $0.macDeviceID == "mac-legacy" }
|
||||
#expect(visible?.teamID == nil)
|
||||
#expect(visible?.customName == "Studio")
|
||||
#expect(visible?.customColor == "palette:4")
|
||||
#expect(visible?.customIcon == "terminal")
|
||||
}
|
||||
|
||||
@Test func activatingVisibleLegacyRowClearsSelectedTeamActiveMac() async throws {
|
||||
let (inner, directory) = try makeInnerStore()
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let store = TeamScopedPairedMacStore(
|
||||
inner: inner,
|
||||
teamIDProvider: { "team-a" }
|
||||
)
|
||||
|
||||
try await inner.upsert(
|
||||
macDeviceID: "mac-legacy",
|
||||
displayName: "Legacy",
|
||||
routes: [try route("10.0.0.1")],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: nil,
|
||||
now: Date(timeIntervalSince1970: 1)
|
||||
)
|
||||
try await inner.upsert(
|
||||
macDeviceID: "mac-team",
|
||||
displayName: "Team",
|
||||
routes: [try route("10.0.0.2")],
|
||||
markActive: true,
|
||||
stackUserID: "user-1",
|
||||
teamID: "team-a",
|
||||
now: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
try await store.setActive(macDeviceID: "mac-legacy", stackUserID: "user-1", teamID: nil)
|
||||
|
||||
let visible = try await store.loadAll(stackUserID: "user-1")
|
||||
#expect(visible.filter(\.isActive).map(\.macDeviceID) == ["mac-legacy"])
|
||||
#expect(try await store.activeMac(stackUserID: "user-1")?.macDeviceID == "mac-legacy")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/// Test double for expected-user token binding.
|
||||
actor TokenProbe {
|
||||
private var userIDs: [String?]
|
||||
private(set) var tokenReads = 0
|
||||
|
||||
init(userIDs: [String?]) {
|
||||
self.userIDs = userIDs
|
||||
}
|
||||
|
||||
func token() -> String? {
|
||||
tokenReads += 1
|
||||
return "token"
|
||||
}
|
||||
|
||||
func currentUserID() -> String? {
|
||||
guard !userIDs.isEmpty else { return nil }
|
||||
return userIDs.removeFirst()
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
public import Foundation
|
||||
|
||||
/// The phone's view of ONE Mac's workspaces: the per-Mac source of truth behind
|
||||
/// the aggregated multi-Mac workspace list. The published flat list (and group
|
||||
/// sections) is a PURE DERIVATION over every Mac's `MacWorkspaceState` — see
|
||||
/// ``MobileWorkspaceAggregation``. Nothing assigns the flat list directly; it is
|
||||
/// always `derive(statesByMac:foregroundMacDeviceID:)`, so a stale or
|
||||
/// half-merged aggregate is unrepresentable.
|
||||
///
|
||||
/// Deliberately transport-agnostic. Today each entry is fed by a direct
|
||||
/// phone→Mac live subscription (N connections). The planned end-state routes
|
||||
/// every Mac through a single Durable Object that the phone holds ONE connection
|
||||
/// to, which delivers per-Mac deltas; the data model and the derivation are
|
||||
/// identical either way — only the writer of these entries changes. So this type
|
||||
/// carries no connection/RPC/route detail, only the observable facts about a
|
||||
/// Mac's workspaces.
|
||||
public struct MacWorkspaceState: Identifiable, Equatable, Sendable {
|
||||
/// The stable device id of the Mac this state describes. Also the dictionary
|
||||
/// key in the aggregate, and the `id` for `Identifiable`.
|
||||
public var macDeviceID: String
|
||||
/// The Mac's user-facing display name, for per-Mac sections/labels.
|
||||
public var displayName: String?
|
||||
/// This Mac's workspaces, each already tagged with `macDeviceID` so the
|
||||
/// derived list can group and filter by machine without re-stamping.
|
||||
public var workspaces: [MobileWorkspacePreview]
|
||||
/// This Mac's workspace groups, in section order (empty when the Mac reports
|
||||
/// none or is too old to emit them).
|
||||
public var groups: [MobileWorkspaceGroupPreview]
|
||||
/// Liveness of THIS Mac's data, so the UI can show per-Mac
|
||||
/// connecting/reconnecting/offline and the derivation can decide whether a
|
||||
/// dropped Mac's last-known rows stay (greyed) or are dropped.
|
||||
public var status: MobileMacConnectionStatus
|
||||
/// Workspace actions supported by this Mac.
|
||||
public var actionCapabilities: MobileWorkspaceActionCapabilities
|
||||
|
||||
/// Stable identity for SwiftUI lists and dictionaries.
|
||||
public var id: String { macDeviceID }
|
||||
|
||||
/// Create one per-Mac workspace state snapshot.
|
||||
public init(
|
||||
macDeviceID: String,
|
||||
displayName: String? = nil,
|
||||
workspaces: [MobileWorkspacePreview] = [],
|
||||
groups: [MobileWorkspaceGroupPreview] = [],
|
||||
status: MobileMacConnectionStatus = .reconnecting,
|
||||
actionCapabilities: MobileWorkspaceActionCapabilities = .none
|
||||
) {
|
||||
self.macDeviceID = macDeviceID
|
||||
self.displayName = displayName
|
||||
self.workspaces = workspaces
|
||||
self.groups = groups
|
||||
self.status = status
|
||||
self.actionCapabilities = actionCapabilities
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
/// Maps a workspace to a stable avatar color slot keyed to its OWNING MACHINE,
|
||||
/// so every workspace on the same Mac shares one color in the aggregated
|
||||
/// multi-Mac list. The UI layer maps a returned slot in `0..<slotCount` to a
|
||||
/// concrete gradient; this stays free of SwiftUI so it is unit-testable.
|
||||
///
|
||||
public struct MachineAvatarPalette: Sendable {
|
||||
/// Default number of distinct color slots. The UI passes its real palette
|
||||
/// count so the slot is always in range.
|
||||
public static let defaultSlotCount = 8
|
||||
|
||||
/// Number of distinct color slots in the target palette.
|
||||
public var slotCount: Int
|
||||
|
||||
/// Create a palette slot resolver.
|
||||
public init(slotCount: Int = Self.defaultSlotCount) {
|
||||
self.slotCount = slotCount
|
||||
}
|
||||
|
||||
/// Stable color slot for a workspace. Keyed to `machineID` so same-machine
|
||||
/// workspaces collide on one color by design; falls back to `fallbackID`
|
||||
/// (the workspace id) when the machine is unknown — e.g. a local single-Mac
|
||||
/// session before its device id resolves — so the avatar still has a stable
|
||||
/// color.
|
||||
public func slot(
|
||||
machineID: String?,
|
||||
fallbackID: String
|
||||
) -> Int {
|
||||
let source = (machineID?.isEmpty == false) ? machineID! : fallbackID
|
||||
// djb2: spreads similar ids (UUID fragments, hostnames that share a
|
||||
// prefix) across distinct slots far better than a scalar sum, which
|
||||
// collides on anagram-like ids. `&*`/`&+` wrap intentionally; the
|
||||
// double-modulo below normalizes the (possibly negative) hash into range.
|
||||
var hash = 5381
|
||||
for scalar in source.unicodeScalars {
|
||||
hash = (hash &* 33) &+ Int(scalar.value)
|
||||
}
|
||||
let count = max(1, slotCount)
|
||||
return ((hash % count) + count) % count
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/// Workspace actions supported by the Mac that owns a workspace row.
|
||||
public struct MobileWorkspaceActionCapabilities: Equatable, Sendable {
|
||||
/// Whether rename and pin/unpin workspace actions are supported.
|
||||
public var supportsWorkspaceActions: Bool
|
||||
/// Whether mark read/unread workspace actions are supported.
|
||||
public var supportsReadStateActions: Bool
|
||||
/// Whether workspace close requests are supported.
|
||||
public var supportsCloseActions: Bool
|
||||
|
||||
/// No workspace actions are supported.
|
||||
public static let none = MobileWorkspaceActionCapabilities()
|
||||
|
||||
/// Create a workspace action capability snapshot.
|
||||
public init(
|
||||
supportsWorkspaceActions: Bool = false,
|
||||
supportsReadStateActions: Bool = false,
|
||||
supportsCloseActions: Bool = false
|
||||
) {
|
||||
self.supportsWorkspaceActions = supportsWorkspaceActions
|
||||
self.supportsReadStateActions = supportsReadStateActions
|
||||
self.supportsCloseActions = supportsCloseActions
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure derivations from the per-Mac state map to the flat, user-facing shapes.
|
||||
///
|
||||
public struct MobileWorkspaceAggregation: Sendable {
|
||||
private let rowIDSeparator = "\u{1F}"
|
||||
|
||||
/// Create a workspace aggregation derivation helper.
|
||||
public init() {}
|
||||
|
||||
/// The Macs in deterministic display order.
|
||||
public func orderedMacIDs(
|
||||
statesByMac: [String: MacWorkspaceState],
|
||||
foregroundMacDeviceID: String?
|
||||
) -> [String] {
|
||||
statesByMac.values.sorted { lhs, rhs in
|
||||
let lhsForeground = lhs.macDeviceID == foregroundMacDeviceID
|
||||
let rhsForeground = rhs.macDeviceID == foregroundMacDeviceID
|
||||
if lhsForeground != rhsForeground { return lhsForeground }
|
||||
let lhsName = lhs.displayName ?? lhs.macDeviceID
|
||||
let rhsName = rhs.displayName ?? rhs.macDeviceID
|
||||
if lhsName != rhsName { return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending }
|
||||
return lhs.macDeviceID < rhs.macDeviceID
|
||||
}.map(\.macDeviceID)
|
||||
}
|
||||
|
||||
/// A distinct stable color index per Mac, keyed by `macDeviceID`.
|
||||
public func machineColorIndex(
|
||||
statesByMac: [String: MacWorkspaceState]
|
||||
) -> [String: Int] {
|
||||
var result: [String: Int] = [:]
|
||||
for (offset, macID) in statesByMac.keys.filter({ !$0.isEmpty }).sorted().enumerated() {
|
||||
result[macID] = offset
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Stable row id for one Mac-local workspace inside the aggregated list.
|
||||
///
|
||||
/// The separator is the ASCII unit separator, which is not emitted by cmux
|
||||
/// workspace ids. The id is opaque and never parsed; the original Mac-local
|
||||
/// id remains on ``MobileWorkspacePreview/remoteWorkspaceID`` for RPC.
|
||||
public func rowID(
|
||||
macDeviceID: String,
|
||||
workspaceID: MobileWorkspacePreview.ID
|
||||
) -> MobileWorkspacePreview.ID {
|
||||
MobileWorkspacePreview.ID(rawValue: "\(macDeviceID)\(rowIDSeparator)\(workspaceID.rawValue)")
|
||||
}
|
||||
|
||||
/// Derive the flat, ordered workspace list across all Macs.
|
||||
public func derivedWorkspaces(
|
||||
statesByMac: [String: MacWorkspaceState],
|
||||
foregroundMacDeviceID: String?
|
||||
) -> [MobileWorkspacePreview] {
|
||||
let colorIndex = machineColorIndex(statesByMac: statesByMac)
|
||||
let shouldScopeRowIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
|
||||
var result: [MobileWorkspacePreview] = []
|
||||
for macID in orderedMacIDs(statesByMac: statesByMac, foregroundMacDeviceID: foregroundMacDeviceID) {
|
||||
guard let state = statesByMac[macID] else { continue }
|
||||
for workspace in state.workspaces {
|
||||
let ownerID = workspace.macDeviceID ?? state.macDeviceID
|
||||
var stamped = workspace
|
||||
if !ownerID.isEmpty {
|
||||
stamped.macDeviceID = ownerID
|
||||
stamped.machineColorIndex = colorIndex[ownerID]
|
||||
}
|
||||
let remoteID = workspace.remoteWorkspaceID ?? workspace.id
|
||||
stamped.remoteWorkspaceID = shouldScopeRowIDs && !ownerID.isEmpty ? remoteID : workspace.remoteWorkspaceID
|
||||
stamped.macConnectionStatus = state.status
|
||||
stamped.actionCapabilities = state.actionCapabilities
|
||||
if shouldScopeRowIDs && !ownerID.isEmpty {
|
||||
stamped.id = rowID(macDeviceID: ownerID, workspaceID: remoteID)
|
||||
}
|
||||
result.append(stamped)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Derive the group sections to show for the foreground Mac.
|
||||
public func derivedGroups(
|
||||
statesByMac: [String: MacWorkspaceState],
|
||||
foregroundMacDeviceID: String?
|
||||
) -> [MobileWorkspaceGroupPreview] {
|
||||
guard let foregroundMacDeviceID, let state = statesByMac[foregroundMacDeviceID] else { return [] }
|
||||
let shouldScopeRowIDs = statesByMac.keys.filter { !$0.isEmpty }.count > 1
|
||||
guard shouldScopeRowIDs, !foregroundMacDeviceID.isEmpty else { return state.groups }
|
||||
let remoteIDByLocalID = Dictionary(
|
||||
uniqueKeysWithValues: state.workspaces.map { workspace in
|
||||
(workspace.id, workspace.remoteWorkspaceID ?? workspace.id)
|
||||
}
|
||||
)
|
||||
return state.groups.map { group in
|
||||
var scoped = group
|
||||
let remoteID = remoteIDByLocalID[group.anchorWorkspaceID] ?? group.anchorWorkspaceID
|
||||
scoped.anchorWorkspaceID = rowID(macDeviceID: foregroundMacDeviceID, workspaceID: remoteID)
|
||||
return scoped
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-18
@@ -1,28 +1,84 @@
|
||||
/// A predicate over workspace rows, shared by every surface that lists
|
||||
/// A compound predicate over workspace rows, shared by every surface that lists
|
||||
/// workspaces (the flat workspace list and the device tree).
|
||||
///
|
||||
/// Modeled as an enum so new filters (e.g. pinned, running agents) are added
|
||||
/// as cases with a `matches` arm, and every menu that offers filters picks up
|
||||
/// the new case from `CaseIterable`. `.all` is the identity filter.
|
||||
public enum MobileWorkspaceListFilter: String, CaseIterable, Hashable, Sendable {
|
||||
/// No filtering; every workspace matches.
|
||||
case all
|
||||
/// Only workspaces with unread activity (the iMessage-style unread dot).
|
||||
case unread
|
||||
/// Two orthogonal, composable dimensions instead of one flat toggle, so the
|
||||
/// aggregated multi-Mac list can express e.g. "unread on Mac X and Mac Y":
|
||||
/// - `readState`: all rows, or only those with unread activity.
|
||||
/// - `machines`: a set of `macDeviceID`s to include; empty means every machine.
|
||||
///
|
||||
/// A row passes when it satisfies BOTH dimensions. The identity filter
|
||||
/// (`readState == .all`, `machines` empty) shows everything.
|
||||
public struct MobileWorkspaceListFilter: Hashable, Sendable {
|
||||
/// Read-state narrowing for the filter.
|
||||
public var readState: MobileWorkspaceReadStateFilter
|
||||
/// `macDeviceID`s to include. Empty means all machines (no machine narrowing).
|
||||
public var machines: Set<String>
|
||||
|
||||
/// Whether `workspace` passes this filter.
|
||||
/// Create a workspace list filter from read-state and machine dimensions.
|
||||
public init(readState: MobileWorkspaceReadStateFilter = .all, machines: Set<String> = []) {
|
||||
self.readState = readState
|
||||
self.machines = machines
|
||||
}
|
||||
|
||||
/// The identity filter: show every workspace.
|
||||
public static let all = MobileWorkspaceListFilter()
|
||||
|
||||
/// Whether `workspace` passes both dimensions.
|
||||
/// - Parameter workspace: The workspace row under consideration.
|
||||
/// - Returns: `true` when the row should be shown.
|
||||
public func matches(_ workspace: MobileWorkspacePreview) -> Bool {
|
||||
switch self {
|
||||
case .all:
|
||||
return true
|
||||
case .unread:
|
||||
return workspace.hasUnread
|
||||
let readOK: Bool
|
||||
switch readState {
|
||||
case .all: readOK = true
|
||||
case .unread: readOK = workspace.hasUnread
|
||||
}
|
||||
// A machine filter only matches rows whose owning Mac is in the set; a
|
||||
// row with an unknown machine (an older Mac that didn't report one) is
|
||||
// excluded while a machine filter is active, since it can't be confirmed
|
||||
// to belong to a selected machine.
|
||||
let machineOK = machines.isEmpty || (workspace.macDeviceID.map(machines.contains) ?? false)
|
||||
return readOK && machineOK
|
||||
}
|
||||
|
||||
/// Whether this filter actually narrows the list (drives the filled-vs-
|
||||
/// outlined filter icon and the empty-state copy).
|
||||
public var isActive: Bool { readState != .all || !machines.isEmpty }
|
||||
|
||||
/// Add or remove a machine from the filter set.
|
||||
public mutating func toggleMachine(_ macDeviceID: String) {
|
||||
if machines.contains(macDeviceID) {
|
||||
machines.remove(macDeviceID)
|
||||
} else {
|
||||
machines.insert(macDeviceID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this filter actually narrows the list (drives the
|
||||
/// filled-vs-outlined filter icon and empty-state copy).
|
||||
public var isActive: Bool { self != .all }
|
||||
/// The distinct machine ids present in a workspace list, in first-appearance
|
||||
/// order. Drives the machine multi-select in the filter menu: only machines
|
||||
/// that actually have rows are offered, and the menu hides the section
|
||||
/// entirely when there are fewer than two. Workspaces with no known machine
|
||||
/// are skipped (they can't be filtered by machine).
|
||||
public static func machineIDs(in workspaces: [MobileWorkspacePreview]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
var ordered: [String] = []
|
||||
for workspace in workspaces {
|
||||
guard let macDeviceID = workspace.macDeviceID else { continue }
|
||||
if seen.insert(macDeviceID).inserted {
|
||||
ordered.append(macDeviceID)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
/// Drop any selected machines that are no longer present in the list, so a
|
||||
/// machine filter for a Mac that disconnected/disappeared does not silently
|
||||
/// hide everything. Returns whether the filter changed.
|
||||
@discardableResult
|
||||
public mutating func pruneMachines(notIn present: [String]) -> Bool {
|
||||
let presentSet = Set(present)
|
||||
let kept = machines.intersection(presentSet)
|
||||
guard kept != machines else { return false }
|
||||
machines = kept
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+44
-1
@@ -25,8 +25,23 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace's stable identifier.
|
||||
/// The workspace's stable row identifier.
|
||||
///
|
||||
/// In a single-Mac list this is the Mac-local workspace id. In the aggregated
|
||||
/// multi-Mac list it may be scoped by the owning Mac so two Macs can expose
|
||||
/// the same local workspace id without colliding in SwiftUI navigation.
|
||||
public var id: ID
|
||||
/// The Mac-local workspace identifier to send back over RPC.
|
||||
///
|
||||
/// Aggregated rows can use a Mac-scoped ``id`` for UI identity while keeping
|
||||
/// this original id for Mac requests. `nil` means ``id`` is already the
|
||||
/// remote id.
|
||||
public var remoteWorkspaceID: ID?
|
||||
/// The stable device id of the Mac this workspace belongs to. Carried so the
|
||||
/// aggregated multi-Mac workspace list can group and filter by machine, and
|
||||
/// so opening a workspace attaches the right Mac. `nil` when connected to a
|
||||
/// Mac old enough not to report it, or before the owning Mac is known.
|
||||
public var macDeviceID: String?
|
||||
/// The Mac window that owns this workspace, when reported by the paired Mac.
|
||||
public var windowID: String?
|
||||
/// The workspace's user-facing display name.
|
||||
@@ -56,6 +71,31 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable {
|
||||
public var hasUnread: Bool
|
||||
/// The terminals contained in the workspace, in display order.
|
||||
public var terminals: [MobileTerminalPreview]
|
||||
/// The owning Mac's DISTINCT color index in the aggregated list, stamped by
|
||||
/// ``MobileWorkspaceAggregation/derivedWorkspaces`` so same-Mac workspaces
|
||||
/// share one avatar color and different Macs are guaranteed distinct. `nil`
|
||||
/// outside the aggregated list (the avatar then falls back to a hash of the
|
||||
/// id). Not part of the Mac's reported data, so it has a default and is set by
|
||||
/// derivation, not the decoders.
|
||||
public var machineColorIndex: Int? = nil
|
||||
/// The owning Mac's user color override ("palette:<n>" or "#RRGGBB"), stamped
|
||||
/// during aggregation so the workspace avatar matches the computer's color.
|
||||
/// `nil` = use ``machineColorIndex`` (the automatic color).
|
||||
public var machineCustomColor: String? = nil
|
||||
/// The owning Mac's user icon override (SF Symbol name or emoji), stamped
|
||||
/// during aggregation. `nil` = the automatic icon.
|
||||
public var machineCustomIcon: String? = nil
|
||||
/// The owning Mac's connection status, stamped during aggregation so rows
|
||||
/// from offline secondary Macs can render unavailable while the foreground
|
||||
/// Mac remains connected. `nil` outside an aggregated/per-Mac derivation.
|
||||
public var macConnectionStatus: MobileMacConnectionStatus? = nil
|
||||
/// Workspace actions supported by the Mac that owns this row.
|
||||
public var actionCapabilities: MobileWorkspaceActionCapabilities = .none
|
||||
|
||||
/// The workspace id to use in RPC params.
|
||||
public var rpcWorkspaceID: ID {
|
||||
remoteWorkspaceID ?? id
|
||||
}
|
||||
|
||||
/// Creates a workspace preview.
|
||||
/// - Parameters:
|
||||
@@ -71,6 +111,7 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable {
|
||||
/// - terminals: The terminals contained in the workspace, in display order.
|
||||
public init(
|
||||
id: ID,
|
||||
macDeviceID: String? = nil,
|
||||
windowID: String? = nil,
|
||||
name: String,
|
||||
isPinned: Bool = false,
|
||||
@@ -82,6 +123,8 @@ public struct MobileWorkspacePreview: Identifiable, Equatable, Sendable {
|
||||
terminals: [MobileTerminalPreview]
|
||||
) {
|
||||
self.id = id
|
||||
self.remoteWorkspaceID = nil
|
||||
self.macDeviceID = macDeviceID
|
||||
self.windowID = windowID
|
||||
self.name = name
|
||||
self.isPinned = isPinned
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/// Read-state narrowing for workspace list filters.
|
||||
public enum MobileWorkspaceReadStateFilter: String, CaseIterable, Hashable, Sendable {
|
||||
/// No read-state narrowing; every row matches.
|
||||
case all
|
||||
/// Only workspaces with unread activity.
|
||||
case unread
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import Testing
|
||||
@testable import CmuxMobileShellModel
|
||||
|
||||
struct MachineAvatarPaletteTests {
|
||||
@Test func sameMachineSharesSlotRegardlessOfWorkspace() {
|
||||
let palette = MachineAvatarPalette()
|
||||
let a = palette.slot(machineID: "mac-studio-abc", fallbackID: "ws-1")
|
||||
let b = palette.slot(machineID: "mac-studio-abc", fallbackID: "ws-2")
|
||||
#expect(a == b)
|
||||
}
|
||||
|
||||
@Test func nilOrEmptyMachineFallsBackToWorkspaceID() {
|
||||
let palette = MachineAvatarPalette()
|
||||
let viaNil = palette.slot(machineID: nil, fallbackID: "ws-42")
|
||||
let viaEmpty = palette.slot(machineID: "", fallbackID: "ws-42")
|
||||
let direct = palette.slot(machineID: "ws-42", fallbackID: "ignored")
|
||||
// Unknown machine keys off the workspace id, so all three agree.
|
||||
#expect(viaNil == viaEmpty)
|
||||
#expect(viaNil == direct)
|
||||
}
|
||||
|
||||
@Test func slotIsAlwaysInRange() {
|
||||
let palette = MachineAvatarPalette(slotCount: 8)
|
||||
for id in ["", "a", "mac-mini-1", "100.64.0.7", "AAAA", "ZZZZ", "🙂x"] {
|
||||
let slot = palette.slot(machineID: id, fallbackID: "fb")
|
||||
#expect(slot >= 0 && slot < 8)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func distinctMachinesSpreadAcrossSlots() {
|
||||
// djb2 should not pile a handful of realistic machine ids onto one slot.
|
||||
let ids = ["cmux-lawrence", "cmux-macmini", "cmux-studio", "macbook-pro", "mac-mini-2"]
|
||||
let palette = MachineAvatarPalette()
|
||||
let slots = Set(ids.map { palette.slot(machineID: $0, fallbackID: "fb") })
|
||||
#expect(slots.count >= 3)
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import Testing
|
||||
@testable import CmuxMobileShellModel
|
||||
|
||||
@Suite struct MobileWorkspaceAggregationTests {
|
||||
private func ws(_ id: String, mac: String, name: String? = nil) -> MobileWorkspacePreview {
|
||||
MobileWorkspacePreview(
|
||||
id: .init(rawValue: id),
|
||||
macDeviceID: mac,
|
||||
name: name ?? id,
|
||||
terminals: []
|
||||
)
|
||||
}
|
||||
|
||||
private func state(_ mac: String, name: String?, _ ids: [String]) -> MacWorkspaceState {
|
||||
MacWorkspaceState(
|
||||
macDeviceID: mac,
|
||||
displayName: name,
|
||||
workspaces: ids.map { ws($0, mac: mac) },
|
||||
status: .connected
|
||||
)
|
||||
}
|
||||
|
||||
@Test func distinctMacsGetDistinctColorIndicesAndSameMacShares() {
|
||||
let states = [
|
||||
"mac-a": state("mac-a", name: "Alpha", ["a1", "a2"]),
|
||||
"mac-b": state("mac-b", name: "Beta", ["b1"]),
|
||||
]
|
||||
let idx = MobileWorkspaceAggregation().machineColorIndex(statesByMac: states)
|
||||
// Different Macs must never collide on one color (the "both yellow" bug).
|
||||
#expect(idx["mac-a"] != idx["mac-b"])
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-a")
|
||||
// Same Mac's workspaces all carry that Mac's single color index.
|
||||
#expect(derived.filter { $0.macDeviceID == "mac-a" }.allSatisfy { $0.machineColorIndex == idx["mac-a"] })
|
||||
#expect(derived.first { $0.macDeviceID == "mac-b" }?.machineColorIndex == idx["mac-b"])
|
||||
}
|
||||
|
||||
@Test func colorIndexIgnoresEmptyMacKeys() {
|
||||
let states = [
|
||||
"": state("", name: nil, ["x"]),
|
||||
"mac-a": state("mac-a", name: "Alpha", ["a1"]),
|
||||
]
|
||||
let idx = MobileWorkspaceAggregation().machineColorIndex(statesByMac: states)
|
||||
#expect(idx[""] == nil)
|
||||
#expect(idx["mac-a"] != nil)
|
||||
}
|
||||
|
||||
@Test func foregroundWorkspacesComeFirst() {
|
||||
let states = [
|
||||
"mac-b": state("mac-b", name: "Beta", ["b1", "b2"]),
|
||||
"mac-a": state("mac-a", name: "Alpha", ["a1"]),
|
||||
]
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-b")
|
||||
// Foreground (mac-b) first regardless of name order, then the rest.
|
||||
#expect(derived.map(\.rpcWorkspaceID.rawValue) == ["b1", "b2", "a1"])
|
||||
}
|
||||
|
||||
@Test func nonForegroundMacsOrderedByDisplayNameThenID() {
|
||||
let states = [
|
||||
"mac-z": state("mac-z", name: "Charlie", ["z1"]),
|
||||
"mac-a": state("mac-a", name: "Alpha", ["a1"]),
|
||||
"mac-m": state("mac-m", name: "Bravo", ["m1"]),
|
||||
]
|
||||
// No foreground: pure name order Alpha, Bravo, Charlie.
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: nil)
|
||||
#expect(derived.map(\.rpcWorkspaceID.rawValue) == ["a1", "m1", "z1"])
|
||||
}
|
||||
|
||||
@Test func keepsSameWorkspaceIDFromDifferentMacsDistinct() {
|
||||
// Workspace ids are Mac-local, so the same raw id on two Macs must render
|
||||
// as two navigable rows while RPC still sends the Mac-local id.
|
||||
let states = [
|
||||
"mac-fg": MacWorkspaceState(macDeviceID: "mac-fg", displayName: "FG", workspaces: [ws("shared", mac: "mac-fg", name: "from-fg")], status: .connected),
|
||||
"mac-bg": MacWorkspaceState(macDeviceID: "mac-bg", displayName: "BG", workspaces: [ws("shared", mac: "mac-bg", name: "from-bg")], status: .connected),
|
||||
]
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
#expect(derived.map(\.name) == ["from-fg", "from-bg"])
|
||||
#expect(Set(derived.map(\.id)).count == 2)
|
||||
#expect(derived.map(\.rpcWorkspaceID.rawValue) == ["shared", "shared"])
|
||||
#expect(derived.map(\.macDeviceID) == ["mac-fg", "mac-bg"])
|
||||
}
|
||||
|
||||
@Test func rowStatusComesFromOwningMac() {
|
||||
let states = [
|
||||
"mac-fg": MacWorkspaceState(macDeviceID: "mac-fg", displayName: "FG", workspaces: [ws("w1", mac: "mac-fg")], status: .connected),
|
||||
"mac-bg": MacWorkspaceState(macDeviceID: "mac-bg", displayName: "BG", workspaces: [ws("w2", mac: "mac-bg")], status: .unavailable),
|
||||
]
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
#expect(derived.first { $0.rpcWorkspaceID.rawValue == "w1" }?.macConnectionStatus == .connected)
|
||||
#expect(derived.first { $0.rpcWorkspaceID.rawValue == "w2" }?.macConnectionStatus == .unavailable)
|
||||
}
|
||||
|
||||
@Test func rowActionCapabilitiesComeFromOwningMac() {
|
||||
let foregroundCapabilities = MobileWorkspaceActionCapabilities(supportsWorkspaceActions: true)
|
||||
let backgroundCapabilities = MobileWorkspaceActionCapabilities(
|
||||
supportsWorkspaceActions: true,
|
||||
supportsReadStateActions: true,
|
||||
supportsCloseActions: true
|
||||
)
|
||||
let states = [
|
||||
"mac-fg": MacWorkspaceState(
|
||||
macDeviceID: "mac-fg",
|
||||
displayName: "FG",
|
||||
workspaces: [ws("w1", mac: "mac-fg")],
|
||||
status: .connected,
|
||||
actionCapabilities: foregroundCapabilities
|
||||
),
|
||||
"mac-bg": MacWorkspaceState(
|
||||
macDeviceID: "mac-bg",
|
||||
displayName: "BG",
|
||||
workspaces: [ws("w2", mac: "mac-bg")],
|
||||
status: .connected,
|
||||
actionCapabilities: backgroundCapabilities
|
||||
),
|
||||
]
|
||||
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
|
||||
#expect(derived.first { $0.rpcWorkspaceID.rawValue == "w1" }?.actionCapabilities == foregroundCapabilities)
|
||||
#expect(derived.first { $0.rpcWorkspaceID.rawValue == "w2" }?.actionCapabilities == backgroundCapabilities)
|
||||
}
|
||||
|
||||
@Test func emptyStateMapDerivesEmptyList() {
|
||||
#expect(MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: [:], foregroundMacDeviceID: "mac-a").isEmpty)
|
||||
}
|
||||
|
||||
@Test func updatingOneMacReflectsImmediatelyInDerivation() {
|
||||
// The core "derived all the way through" guarantee: mutate one Mac's
|
||||
// state and the derived list reflects it with no explicit publish.
|
||||
var states = [
|
||||
"mac-fg": state("mac-fg", name: "FG", ["w1"]),
|
||||
"mac-bg": state("mac-bg", name: "BG", ["w2"]),
|
||||
]
|
||||
#expect(MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-fg").count == 2)
|
||||
// A workspace is created on the background Mac.
|
||||
states["mac-bg"]?.workspaces.append(ws("w3", mac: "mac-bg"))
|
||||
let derived = MobileWorkspaceAggregation().derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
#expect(derived.map(\.rpcWorkspaceID.rawValue) == ["w1", "w2", "w3"])
|
||||
}
|
||||
|
||||
private func group(_ id: String, anchor: String) -> MobileWorkspaceGroupPreview {
|
||||
MobileWorkspaceGroupPreview(id: .init(rawValue: id), name: id, anchorWorkspaceID: .init(rawValue: anchor))
|
||||
}
|
||||
|
||||
@Test func groupsComeFromForegroundMac() {
|
||||
let states = [
|
||||
"mac-fg": MacWorkspaceState(macDeviceID: "mac-fg", displayName: "FG", workspaces: [], groups: [group("g1", anchor: "w1")], status: .connected),
|
||||
"mac-bg": MacWorkspaceState(macDeviceID: "mac-bg", displayName: "BG", workspaces: [], groups: [group("g2", anchor: "w2")], status: .connected),
|
||||
]
|
||||
let groups = MobileWorkspaceAggregation().derivedGroups(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
#expect(groups.map { $0.id.rawValue } == ["g1"])
|
||||
}
|
||||
|
||||
@Test func foregroundGroupAnchorsFollowScopedWorkspaceRowIDs() {
|
||||
let aggregation = MobileWorkspaceAggregation()
|
||||
var foregroundWorkspace = ws("local-w1", mac: "mac-fg")
|
||||
foregroundWorkspace.remoteWorkspaceID = .init(rawValue: "remote-w1")
|
||||
let states = [
|
||||
"mac-fg": MacWorkspaceState(
|
||||
macDeviceID: "mac-fg",
|
||||
displayName: "FG",
|
||||
workspaces: [foregroundWorkspace],
|
||||
groups: [group("g1", anchor: "local-w1")],
|
||||
status: .connected
|
||||
),
|
||||
"mac-bg": state("mac-bg", name: "BG", ["w2"]),
|
||||
]
|
||||
|
||||
let workspaces = aggregation.derivedWorkspaces(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
let groups = aggregation.derivedGroups(statesByMac: states, foregroundMacDeviceID: "mac-fg")
|
||||
|
||||
#expect(groups.first?.anchorWorkspaceID == workspaces.first?.id)
|
||||
#expect(groups.first?.anchorWorkspaceID.rawValue == "mac-fg\u{1F}remote-w1")
|
||||
}
|
||||
}
|
||||
+67
-15
@@ -2,9 +2,10 @@ import Testing
|
||||
@testable import CmuxMobileShellModel
|
||||
|
||||
@Suite struct MobileWorkspaceListFilterTests {
|
||||
private func workspace(hasUnread: Bool) -> MobileWorkspacePreview {
|
||||
private func workspace(_ id: String, hasUnread: Bool, mac: String? = nil) -> MobileWorkspacePreview {
|
||||
MobileWorkspacePreview(
|
||||
id: .init(rawValue: hasUnread ? "unread" : "read"),
|
||||
id: .init(rawValue: id),
|
||||
macDeviceID: mac,
|
||||
name: "ws",
|
||||
hasUnread: hasUnread,
|
||||
terminals: []
|
||||
@@ -12,22 +13,73 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func allMatchesEverything() {
|
||||
#expect(MobileWorkspaceListFilter.all.matches(workspace(hasUnread: false)))
|
||||
#expect(MobileWorkspaceListFilter.all.matches(workspace(hasUnread: true)))
|
||||
#expect(!MobileWorkspaceListFilter.all.isActive)
|
||||
let all = MobileWorkspaceListFilter.all
|
||||
#expect(all.matches(workspace("a", hasUnread: false, mac: "mac-1")))
|
||||
#expect(all.matches(workspace("b", hasUnread: true, mac: "mac-2")))
|
||||
#expect(!all.isActive)
|
||||
}
|
||||
|
||||
@Test func unreadMatchesOnlyUnreadWorkspaces() {
|
||||
#expect(MobileWorkspaceListFilter.unread.matches(workspace(hasUnread: true)))
|
||||
#expect(!MobileWorkspaceListFilter.unread.matches(workspace(hasUnread: false)))
|
||||
#expect(MobileWorkspaceListFilter.unread.isActive)
|
||||
@Test func unreadDimensionMatchesOnlyUnread() {
|
||||
let unread = MobileWorkspaceListFilter(readState: .unread)
|
||||
#expect(unread.matches(workspace("a", hasUnread: true)))
|
||||
#expect(!unread.matches(workspace("b", hasUnread: false)))
|
||||
#expect(unread.isActive)
|
||||
}
|
||||
|
||||
/// The exact narrowing both list surfaces apply (`workspaces.filter(filter.matches)`):
|
||||
/// unread keeps only unread rows, in order; all is the identity.
|
||||
@Test func filteringNarrowsAWorkspaceArray() {
|
||||
let rows = [workspace(hasUnread: false), workspace(hasUnread: true)]
|
||||
#expect(rows.filter { MobileWorkspaceListFilter.unread.matches($0) }.map(\.id.rawValue) == ["unread"])
|
||||
#expect(rows.filter { MobileWorkspaceListFilter.all.matches($0) }.count == rows.count)
|
||||
@Test func machineDimensionMatchesOnlySelectedMacs() {
|
||||
let onMac1 = MobileWorkspaceListFilter(machines: ["mac-1"])
|
||||
#expect(onMac1.matches(workspace("a", hasUnread: false, mac: "mac-1")))
|
||||
#expect(!onMac1.matches(workspace("b", hasUnread: true, mac: "mac-2")))
|
||||
// A workspace with no known machine is excluded while a machine filter is active.
|
||||
#expect(!onMac1.matches(workspace("c", hasUnread: true, mac: nil)))
|
||||
#expect(onMac1.isActive)
|
||||
}
|
||||
|
||||
@Test func dimensionsComposeUnreadOnSpecificMac() {
|
||||
// "unread on mac-1 and mac-2" — the exact compound case Lawrence asked for.
|
||||
let filter = MobileWorkspaceListFilter(readState: .unread, machines: ["mac-1", "mac-2"])
|
||||
let rows = [
|
||||
workspace("a", hasUnread: true, mac: "mac-1"), // keep
|
||||
workspace("b", hasUnread: false, mac: "mac-1"), // drop (read)
|
||||
workspace("c", hasUnread: true, mac: "mac-3"), // drop (other mac)
|
||||
workspace("d", hasUnread: true, mac: "mac-2"), // keep
|
||||
]
|
||||
#expect(rows.filter(filter.matches).map(\.id.rawValue) == ["a", "d"])
|
||||
}
|
||||
|
||||
@Test func emptyMachineSetMeansAllMachines() {
|
||||
let unreadAnyMac = MobileWorkspaceListFilter(readState: .unread, machines: [])
|
||||
#expect(unreadAnyMac.matches(workspace("a", hasUnread: true, mac: "mac-9")))
|
||||
#expect(unreadAnyMac.matches(workspace("b", hasUnread: true, mac: nil)))
|
||||
}
|
||||
|
||||
@Test func machineIDsAreDistinctInFirstAppearanceOrder() {
|
||||
let rows = [
|
||||
workspace("a", hasUnread: false, mac: "mac-2"),
|
||||
workspace("b", hasUnread: false, mac: "mac-1"),
|
||||
workspace("c", hasUnread: false, mac: "mac-2"), // dup
|
||||
workspace("d", hasUnread: false, mac: nil), // skipped
|
||||
]
|
||||
#expect(MobileWorkspaceListFilter.machineIDs(in: rows) == ["mac-2", "mac-1"])
|
||||
}
|
||||
|
||||
@Test func pruneMachinesDropsAbsentSelections() {
|
||||
var filter = MobileWorkspaceListFilter(readState: .unread, machines: ["mac-1", "mac-gone"])
|
||||
let changed = filter.pruneMachines(notIn: ["mac-1", "mac-2"])
|
||||
#expect(changed)
|
||||
#expect(filter.machines == ["mac-1"])
|
||||
// Idempotent when nothing to prune.
|
||||
let secondChange = filter.pruneMachines(notIn: ["mac-1", "mac-2"])
|
||||
#expect(!secondChange)
|
||||
}
|
||||
|
||||
@Test func toggleMachineAddsThenRemoves() {
|
||||
var filter = MobileWorkspaceListFilter.all
|
||||
filter.toggleMachine("mac-1")
|
||||
#expect(filter.machines == ["mac-1"])
|
||||
#expect(filter.isActive)
|
||||
filter.toggleMachine("mac-1")
|
||||
#expect(filter.machines.isEmpty)
|
||||
#expect(!filter.isActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +78,12 @@ struct CMUXMobileRootView: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
// `WorkspaceListLayoutPreviewView` is `#if DEBUG`-only (a simulator
|
||||
// screenshot fixture), so referencing it directly in `rootContent` breaks the
|
||||
// Release archive ("cannot find ... in scope"). Gate the reference here, the
|
||||
// same way `terminalLayoutPreview` does, so Release compiles to `EmptyView`.
|
||||
/// DEBUG-only wrapper so Release/iOS archives never reference the
|
||||
/// `#if DEBUG`-gated `WorkspaceListLayoutPreviewView` type directly (a
|
||||
/// simulator screenshot fixture). Swift type-checks every `rootContent`
|
||||
/// branch even when `shouldShowWorkspaceListLayoutPreview` is statically
|
||||
/// false in Release, so gate the reference here, the same way
|
||||
/// `terminalLayoutPreview` does, and Release compiles to `EmptyView`.
|
||||
@ViewBuilder private var workspaceListLayoutPreview: some View {
|
||||
#if os(iOS) && DEBUG
|
||||
WorkspaceListLayoutPreviewView()
|
||||
@@ -120,6 +122,14 @@ struct CMUXMobileRootView: View {
|
||||
pushCoordinator.workspacesDidChange()
|
||||
}
|
||||
#endif
|
||||
.onChange(of: authManager.selectedTeamID) { _, _ in
|
||||
// The user switched Stack teams (from the nav drawer). Lazily re-scope
|
||||
// the team-bound state (presence, registry, paired-Mac backup,
|
||||
// aggregation) to the new team without dropping the live terminal. The
|
||||
// drawer only writes `selectedTeamID`; this is the single observation
|
||||
// point, so every entrypoint that changes the team flows through here.
|
||||
store.currentTeamDidChange()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
store.resumeForegroundRefresh()
|
||||
@@ -201,7 +211,9 @@ struct CMUXMobileRootView: View {
|
||||
// paired-but-offline user (who can reach here after a failed
|
||||
// reconnect) is excluded by the gate and falls through to pairing.
|
||||
onboardingFlow
|
||||
} else if store.connectionState != .connected {
|
||||
} else if store.connectionState != .connected && !store.hasKnownPairedMac {
|
||||
// ONLY when there are no saved Macs at all: the add-device flow (it
|
||||
// auto-presents the pairing sheet since there is nothing to list).
|
||||
DisconnectedWorkspaceShellView(
|
||||
hasKnownPairedMac: store.hasKnownPairedMac,
|
||||
showAddDevice: showAddDevice,
|
||||
@@ -209,11 +221,14 @@ struct CMUXMobileRootView: View {
|
||||
setupHelpHighlight: disconnectedSetupHelpHighlight,
|
||||
store: store
|
||||
)
|
||||
.onAppear {
|
||||
showAddDevice()
|
||||
}
|
||||
} else {
|
||||
WorkspaceShellView(store: store, signOut: signOut)
|
||||
// Connected, OR we have saved Macs and are auto-connecting in the
|
||||
// background: always show the integrated cross-Mac workspace list, so
|
||||
// the user never sees a "Your Macs" picker screen. The list renders
|
||||
// whatever workspaces have aggregated (foreground + live secondary
|
||||
// subscriptions); the foreground connection is established without any
|
||||
// tap. Opening a workspace attaches its Mac on demand.
|
||||
WorkspaceShellView(store: store, signOut: signOut, showAddDevice: showAddDevice)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Persists which device / tag rows are expanded in the device tree, keyed by a
|
||||
/// stable id, so the tree restores its open/closed shape across launches.
|
||||
///
|
||||
/// A pure value type over a `Set<String>` of expanded ids with a string
|
||||
/// round-trip for `@AppStorage`. Kept in the view layer (an `@AppStorage` string
|
||||
/// behind this codec); ids are never threaded through rows, so no `@Observable`
|
||||
/// store crosses the tree's `List`/`DisclosureGroup` boundary.
|
||||
public struct DeviceTreeExpansionStore: Equatable, Sendable {
|
||||
public private(set) var expandedIDs: Set<String>
|
||||
|
||||
public init(expandedIDs: Set<String> = []) {
|
||||
self.expandedIDs = expandedIDs
|
||||
}
|
||||
|
||||
/// Decode from the `@AppStorage` string (newline-separated ids). Blank lines
|
||||
/// are ignored so an empty/whitespace store decodes to no expansion.
|
||||
public init(storage: String) {
|
||||
let ids = storage
|
||||
.split(separator: "\n", omittingEmptySubsequences: true)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
self.expandedIDs = Set(ids)
|
||||
}
|
||||
|
||||
/// Encode to the `@AppStorage` string. Sorted for a stable representation so
|
||||
/// equal sets always serialize identically.
|
||||
public var storage: String {
|
||||
expandedIDs.sorted().joined(separator: "\n")
|
||||
}
|
||||
|
||||
public func isExpanded(_ id: String) -> Bool {
|
||||
expandedIDs.contains(id)
|
||||
}
|
||||
|
||||
public mutating func setExpanded(_ id: String, _ expanded: Bool) {
|
||||
if expanded {
|
||||
expandedIDs.insert(id)
|
||||
} else {
|
||||
expandedIDs.remove(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import CMUXMobileCore
|
||||
|
||||
/// The reachable endpoint (host:port) the phone would dial for a Computers row.
|
||||
extension CmxAttachRoute {
|
||||
static func deviceTreeRouteDescription(for routes: [CmxAttachRoute]) -> String? {
|
||||
func endpoint(_ route: CmxAttachRoute) -> String? {
|
||||
if case let .hostPort(host, port) = route.endpoint { return "\(host):\(port)" }
|
||||
return nil
|
||||
}
|
||||
if let nonLoopback = routes.first(where: { $0.kind != .debugLoopback }),
|
||||
let endpoint = endpoint(nonLoopback) {
|
||||
return endpoint
|
||||
}
|
||||
return routes.lazy.compactMap(endpoint).first
|
||||
}
|
||||
}
|
||||
@@ -1,260 +1,16 @@
|
||||
#if os(iOS)
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// Value snapshots + closure actions for the device tree rows. Nothing here holds
|
||||
// an `@Observable` store, so these rows sit safely below the tree's `List`
|
||||
// boundary (see AGENTS.md snapshot-boundary rule).
|
||||
// Value snapshots for the Computers screen rows. Nothing here holds an
|
||||
// `@Observable` store, so rows that consume these sit safely below the screen's
|
||||
// `List` boundary (see AGENTS.md snapshot-boundary rule).
|
||||
|
||||
/// Live presence for a device row, rolled up from the presence service's
|
||||
/// per-instance heartbeats (device online = any instance online). `nil` when
|
||||
/// the presence service has no record of the device, in which case the row
|
||||
/// falls back to its registry "last seen" hint.
|
||||
/// Live presence for a computer, rolled up from the presence service's
|
||||
/// per-instance heartbeats (a computer is online if any instance is online).
|
||||
/// `nil` when the presence service has no record, in which case the row falls
|
||||
/// back to the registry "last seen" hint.
|
||||
enum DeviceTreePresence: Equatable {
|
||||
case online
|
||||
case offline(lastSeenAt: Date)
|
||||
}
|
||||
|
||||
/// Immutable per-device snapshot for the device (top-level) row.
|
||||
struct DeviceTreeDeviceSnapshot: Equatable {
|
||||
let deviceId: String
|
||||
let title: String
|
||||
let platform: String
|
||||
let lastSeenAt: Date
|
||||
let instanceCount: Int
|
||||
/// Whether the live connection currently targets this device.
|
||||
let isConnected: Bool
|
||||
/// The live connection status, present only for the connected device. `nil`
|
||||
/// for every other device, which is described by live presence (below) or
|
||||
/// its last-seen time.
|
||||
let liveStatus: MobileMacConnectionStatus?
|
||||
/// Live presence from the heartbeat service for non-connected devices.
|
||||
let presence: DeviceTreePresence?
|
||||
}
|
||||
|
||||
/// Immutable per-instance snapshot for an app-instance (tag) row.
|
||||
struct DeviceTreeInstanceSnapshot: Equatable {
|
||||
let tag: String
|
||||
let lastSeenAt: Date
|
||||
/// Whether this instance advertises at least one reachable route.
|
||||
let hasRoutes: Bool
|
||||
/// Workspaces visible under this instance (non-zero only for the active
|
||||
/// instance, since the registry carries routes, not workspaces).
|
||||
let workspaceCount: Int
|
||||
/// Whether this instance is the build the live connection currently targets
|
||||
/// (matched by route). Only the active instance shows live workspaces; other
|
||||
/// tags on the same device offer a Connect affordance.
|
||||
let isActiveInstance: Bool
|
||||
}
|
||||
|
||||
/// A device (Mac/host) row: name, platform icon, and live-or-last-seen state,
|
||||
/// with a disclosure chevron to reveal its tagged builds.
|
||||
struct DeviceTreeDeviceRow: View {
|
||||
let device: DeviceTreeDeviceSnapshot
|
||||
let isExpanded: Bool
|
||||
let setExpanded: (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
setExpanded(!isExpanded)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: chevronSymbol)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 12)
|
||||
Image(systemName: platformSymbol)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(device.title)
|
||||
.foregroundStyle(.primary)
|
||||
Text(statusLine)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let liveStatus = device.liveStatus {
|
||||
Image(systemName: liveStatus.symbolName)
|
||||
.foregroundStyle(liveStatus.tintColor)
|
||||
.accessibilityLabel(liveStatus.label)
|
||||
} else if let presence = device.presence {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(presence == .online ? Color.green : Color.secondary.opacity(0.5))
|
||||
.accessibilityLabel(presenceLabel(presence))
|
||||
.accessibilityIdentifier(
|
||||
"MobileDeviceTreePresence-\(device.deviceId)-\(presence == .online ? "online" : "offline")"
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("MobileDeviceTreeDeviceRow-\(device.deviceId)")
|
||||
.accessibilityHint(
|
||||
isExpanded
|
||||
? L10n.string("mobile.deviceTree.collapseHint", defaultValue: "Collapse builds")
|
||||
: L10n.string("mobile.deviceTree.expandHint", defaultValue: "Expand builds")
|
||||
)
|
||||
}
|
||||
|
||||
private var chevronSymbol: String {
|
||||
isExpanded ? "chevron.down" : "chevron.right"
|
||||
}
|
||||
|
||||
private var platformSymbol: String {
|
||||
switch device.platform.lowercased() {
|
||||
case "linux", "windows":
|
||||
return "server.rack"
|
||||
default:
|
||||
return "desktopcomputer"
|
||||
}
|
||||
}
|
||||
|
||||
/// Live status text for the connected device, live presence for every
|
||||
/// other device the heartbeat service knows, otherwise the relative
|
||||
/// last-seen time as a best-effort liveness hint.
|
||||
private var statusLine: String {
|
||||
if let liveStatus = device.liveStatus {
|
||||
return liveStatus.label
|
||||
}
|
||||
switch device.presence {
|
||||
case .online:
|
||||
return L10n.string("mobile.deviceTree.online", defaultValue: "Online")
|
||||
case .offline(let lastSeenAt):
|
||||
// Presence heartbeats are usually fresher than the registry's
|
||||
// last registration write; show the most recent of the two.
|
||||
return lastSeenLine(max(lastSeenAt, device.lastSeenAt))
|
||||
case nil:
|
||||
return lastSeenLine(device.lastSeenAt)
|
||||
}
|
||||
}
|
||||
|
||||
private func lastSeenLine(_ lastSeenAt: Date) -> String {
|
||||
String(
|
||||
format: L10n.string("mobile.deviceTree.lastSeenFormat", defaultValue: "Last seen %@"),
|
||||
lastSeenAt.formatted(.relative(presentation: .named))
|
||||
)
|
||||
}
|
||||
|
||||
private func presenceLabel(_ presence: DeviceTreePresence) -> String {
|
||||
switch presence {
|
||||
case .online:
|
||||
return L10n.string("mobile.deviceTree.online", defaultValue: "Online")
|
||||
case .offline:
|
||||
return L10n.string("mobile.deviceTree.offline", defaultValue: "Offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An app-instance (tag) row under a device: the build tag, its workspace count
|
||||
/// or connect affordance, with a disclosure chevron to reveal workspaces.
|
||||
struct DeviceTreeInstanceRow: View {
|
||||
let instance: DeviceTreeInstanceSnapshot
|
||||
let isExpanded: Bool
|
||||
let setExpanded: (Bool) -> Void
|
||||
/// Connect-on-tap for a non-connected instance, or `nil` when there is
|
||||
/// nothing to connect (already the live build, or no reachable route).
|
||||
let connect: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
setExpanded(!isExpanded)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 12)
|
||||
Image(systemName: "shippingbox")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(instance.tag)
|
||||
.foregroundStyle(.primary)
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let connect {
|
||||
Button {
|
||||
connect()
|
||||
} label: {
|
||||
Text(L10n.string("mobile.deviceTree.connect", defaultValue: "Connect"))
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.accessibilityIdentifier("MobileDeviceTreeConnect-\(instance.tag)")
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 24, bottom: 4, trailing: 12))
|
||||
.accessibilityIdentifier("MobileDeviceTreeInstanceRow-\(instance.tag)")
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if instance.isActiveInstance {
|
||||
return L10n.terminalCountWorkspaces(instance.workspaceCount)
|
||||
}
|
||||
if !instance.hasRoutes {
|
||||
return L10n.string("mobile.deviceTree.noRoutes", defaultValue: "Not reachable")
|
||||
}
|
||||
let relative = instance.lastSeenAt.formatted(.relative(presentation: .named))
|
||||
return String(
|
||||
format: L10n.string("mobile.deviceTree.lastSeenFormat", defaultValue: "Last seen %@"),
|
||||
relative
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A leaf placeholder shown when an expanded instance has no visible workspaces:
|
||||
/// either it is not the connected build (offer Connect) or it is connected but
|
||||
/// has no workspaces yet.
|
||||
struct DeviceTreeWorkspacePlaceholderRow: View {
|
||||
let isActiveInstance: Bool
|
||||
let hasRoutes: Bool
|
||||
let connect: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
if let connect, !isActiveInstance {
|
||||
Button {
|
||||
connect()
|
||||
} label: {
|
||||
Text(L10n.string("mobile.deviceTree.connectToView", defaultValue: "Connect to view"))
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 36, bottom: 4, trailing: 12))
|
||||
.accessibilityIdentifier("MobileDeviceTreeWorkspacePlaceholder")
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
if isActiveInstance {
|
||||
return L10n.string("mobile.deviceTree.noWorkspaces", defaultValue: "No workspaces yet")
|
||||
}
|
||||
if !hasRoutes {
|
||||
return L10n.string("mobile.deviceTree.noRoutes", defaultValue: "Not reachable")
|
||||
}
|
||||
return L10n.string(
|
||||
"mobile.deviceTree.connectToSeeWorkspaces",
|
||||
defaultValue: "Connect to this build to see its workspaces"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,72 +1,113 @@
|
||||
#if os(iOS)
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
/// The hierarchical device tree: the team's registered devices (Macs/hosts) →
|
||||
/// their cmux app instances (tags) → that instance's workspaces → tap to open.
|
||||
/// The Computers screen: the Macs signed in to the user's account, each shown
|
||||
/// with its name, live/last-seen status, and workspace count. There is no longer
|
||||
/// a "connect to a device" step — workspaces from every computer already appear
|
||||
/// together in the main list — so this screen is now for *managing* computers:
|
||||
/// see their details (online state, when last seen, how many workspaces) and add
|
||||
/// or remove one. The data is the durable-object–backed device registry (with a
|
||||
/// paired-Mac fallback) plus live presence.
|
||||
///
|
||||
/// This is the new primary multi-device navigation, built on the merged device
|
||||
/// registry (`GET /api/devices`, the `devices` + `device_app_instances` tables).
|
||||
/// Each top-level row is a registered device with its live or last-seen state;
|
||||
/// expanding a device reveals its tagged builds; expanding a tag reveals that
|
||||
/// build's workspaces. Workspaces only populate for the *currently connected*
|
||||
/// instance (the registry carries routes, not workspaces); tapping a tag that is
|
||||
/// not connected connects to it first, after which its workspaces appear.
|
||||
///
|
||||
/// Snapshot boundary (see AGENTS.md): every row below the `List` boundary takes
|
||||
/// immutable value snapshots plus a closure action bundle (``DeviceTreeActions``)
|
||||
/// only — no `@Observable`/`store` reference crosses into a row, so an orthogonal
|
||||
/// `@Published` change can't thrash the lazy list. The single `@Bindable store`
|
||||
/// lives here at the boundary; below it everything is values.
|
||||
/// Snapshot boundary (see AGENTS.md): every row below the `List` takes an
|
||||
/// immutable ``MacComputerSnapshot`` value only — no `@Observable`/`store`
|
||||
/// reference crosses into a row. The single `@Bindable store` lives here at the
|
||||
/// boundary; actions are plain closures.
|
||||
struct DeviceTreeView: View {
|
||||
@Bindable var store: CMUXMobileShellStore
|
||||
/// Open a workspace (the existing tap-to-open path). Forwarded from the shell.
|
||||
/// Open a workspace (forwarded from the shell). Unused by the management list
|
||||
/// today; kept so a future "show this computer's workspaces" tap can use it.
|
||||
let selectWorkspace: (MobileWorkspacePreview.ID) -> Void
|
||||
/// Present the add-device (pairing) flow. `nil` hides the add affordance.
|
||||
var showAddDevice: (() -> Void)?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
/// Display preferences (title wrapping, preview line count) shared with the
|
||||
/// flat workspace list, read here at the snapshot boundary and passed down
|
||||
/// as values so tree workspace rows render identically to flat-list rows.
|
||||
@Environment(MobileDisplaySettings.self) private var displaySettings
|
||||
|
||||
/// Persisted expansion shape, encoded as a newline-separated id string.
|
||||
@AppStorage("cmux.mobile.deviceTree.expanded") private var expandedStorage = ""
|
||||
@State private var isRefreshing = false
|
||||
/// The active workspace-row filter (All / Unread), the same shared model the
|
||||
/// flat list uses, applied to every expanded instance's workspace leaves.
|
||||
@State private var filter: MobileWorkspaceListFilter = .all
|
||||
/// The computer pending a remove confirmation.
|
||||
@State private var pendingRemoval: MacComputerSnapshot?
|
||||
|
||||
private var expansion: DeviceTreeExpansionStore {
|
||||
DeviceTreeExpansionStore(storage: expandedStorage)
|
||||
}
|
||||
|
||||
/// Devices the phone can attach to (mac/linux/windows hosts). The phone never
|
||||
/// controls itself, so an `ios` row is filtered out rather than shown as a
|
||||
/// tappable, dead host. Sourced from ``CMUXMobileShellStore/deviceTreeDevices``
|
||||
/// so it falls back to locally paired Macs when the registry is unavailable.
|
||||
private var controllableDevices: [RegistryDevice] {
|
||||
store.deviceTreeDevices.filter(\.isControllableHost)
|
||||
/// The user's computers as immutable snapshots, sourced from the paired-Mac
|
||||
/// backup (`pairedMacs`) — this feature's source of truth, the same set that
|
||||
/// feeds the workspace aggregation, and the one ``CMUXMobileShellStore/forgetMac``
|
||||
/// actually removes. (Building from `deviceTreeDevices`, which prefers the team
|
||||
/// registry, would make Remove ineffective: a registry-backed row reappears on
|
||||
/// the next registry load.) Each is enriched with presence, live status, and how
|
||||
/// many aggregated workspaces it contributes.
|
||||
private var computers: [MacComputerSnapshot] {
|
||||
let workspaces = store.workspaces
|
||||
let colorIndex = store.machineColorIndex
|
||||
// The PHONE's own per-Mac connection (foreground or live secondary) — the
|
||||
// source of truth for the dot, distinct from presence.
|
||||
let connectionStatuses = store.macConnectionStatuses
|
||||
return store.pairedMacs.map { mac in
|
||||
let summary = store.presenceMap.deviceSummary(deviceId: mac.macDeviceID)
|
||||
let presence: DeviceTreePresence? = summary
|
||||
.map { $0.online ? .online : .offline(lastSeenAt: $0.lastSeenAt) }
|
||||
return MacComputerSnapshot(
|
||||
deviceId: mac.macDeviceID,
|
||||
title: mac.resolvedName,
|
||||
platform: "mac",
|
||||
colorIndex: colorIndex[mac.macDeviceID],
|
||||
customColor: mac.customColor,
|
||||
customIcon: mac.customIcon,
|
||||
connectionStatus: connectionStatuses[mac.macDeviceID],
|
||||
presence: presence,
|
||||
buildLabel: summary?.buildLabel,
|
||||
routeDescription: CmxAttachRoute.deviceTreeRouteDescription(for: mac.routes),
|
||||
lastSeenAt: mac.lastSeenAt,
|
||||
workspaceCount: workspaces.filter { $0.macDeviceID == mac.macDeviceID }.count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if controllableDevices.isEmpty {
|
||||
if computers.isEmpty {
|
||||
emptySection
|
||||
} else {
|
||||
ForEach(controllableDevices) { device in
|
||||
deviceSection(device)
|
||||
Section {
|
||||
ForEach(computers) { computer in
|
||||
NavigationLink(value: computer.deviceId) {
|
||||
MacComputerRow(computer: computer)
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
removeButton(for: computer)
|
||||
}
|
||||
.contextMenu {
|
||||
removeButton(for: computer)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text(L10n.string(
|
||||
"mobile.computers.footer",
|
||||
defaultValue: "The Macs signed in to your account. Workspaces from every computer appear together in the main list."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(L10n.string("mobile.deviceTree.title", defaultValue: "Devices"))
|
||||
.navigationDestination(for: String.self) { deviceId in
|
||||
MacComputerDetailView(store: store, macDeviceID: deviceId)
|
||||
}
|
||||
.navigationTitle(L10n.string("mobile.computers.title", defaultValue: "Computers"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
WorkspaceListFilterMenu(filter: $filter)
|
||||
if showAddDevice != nil {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showAddDevice?()
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel(L10n.string("mobile.computers.add", defaultValue: "Add Computer"))
|
||||
.accessibilityIdentifier("MobileComputersAddButton")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.string("mobile.common.done", defaultValue: "Done")) {
|
||||
@@ -75,226 +116,96 @@ struct DeviceTreeView: View {
|
||||
.accessibilityIdentifier("MobileDeviceTreeDone")
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await store.loadPairedMacs()
|
||||
await store.loadRegistryDevices()
|
||||
}
|
||||
.refreshable { await reload() }
|
||||
.task {
|
||||
// Load the local paired Macs first so the tree has a fallback
|
||||
// source the instant it appears, then refresh from the registry.
|
||||
await store.loadPairedMacs()
|
||||
await store.loadRegistryDevices()
|
||||
// This screen is the user's connection-debug view. The online dots
|
||||
// (presence) and secondary workspace counts already update live via
|
||||
// push subscriptions, so keeping it "live" just needs a gentle,
|
||||
// timer-driven refresh of the local rows + connected foreground state.
|
||||
// `refreshComputersScreen()` deliberately does NOT dial offline Macs
|
||||
// on the timer (that would fan out a reconnect storm to every saved
|
||||
// Mac); presence-push recovery and the explicit pull-to-refresh /
|
||||
// per-Mac Reconnect button handle reconnects. The timer sequence is
|
||||
// cancelled on dismiss by the surrounding SwiftUI `.task`.
|
||||
await reload()
|
||||
for await _ in Timer.publish(every: 10, on: .main, in: .common).autoconnect().values {
|
||||
await store.refreshComputersScreen()
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
removeTitle(pendingRemoval),
|
||||
isPresented: removalDialogBinding,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let pending = pendingRemoval {
|
||||
Button(
|
||||
L10n.string("mobile.computers.remove", defaultValue: "Remove"),
|
||||
role: .destructive
|
||||
) {
|
||||
let deviceId = pending.deviceId
|
||||
pendingRemoval = nil
|
||||
Task {
|
||||
await store.forgetMac(macDeviceID: deviceId)
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel"), role: .cancel) {
|
||||
pendingRemoval = nil
|
||||
}
|
||||
} message: {
|
||||
Text(L10n.string(
|
||||
"mobile.computers.removeMessage",
|
||||
defaultValue: "This computer and its workspaces stop appearing here. Pair it again to add it back."
|
||||
))
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("MobileDeviceTree")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func removeButton(for computer: MacComputerSnapshot) -> some View {
|
||||
Button(role: .destructive) {
|
||||
pendingRemoval = computer
|
||||
} label: {
|
||||
Label(
|
||||
L10n.string("mobile.computers.remove", defaultValue: "Remove"),
|
||||
systemImage: "trash"
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("MobileComputerRemove-\(computer.deviceId)")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptySection: some View {
|
||||
Section {
|
||||
Text(L10n.string(
|
||||
"mobile.deviceTree.empty",
|
||||
defaultValue: "No registered devices yet. Pair a Mac to see it here."
|
||||
"mobile.computers.empty",
|
||||
defaultValue: "No computers yet. Add one to see its workspaces here."
|
||||
))
|
||||
.foregroundStyle(.secondary)
|
||||
} footer: {
|
||||
Text(L10n.string(
|
||||
"mobile.deviceTree.footer",
|
||||
defaultValue: "Devices and their cmux builds come from your team's registry. Tap a build to connect, then a workspace to open it."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func deviceSection(_ device: RegistryDevice) -> some View {
|
||||
let connectedID = store.connectedMacDeviceID
|
||||
let isConnectedDevice = device.deviceId == connectedID
|
||||
// Live status only exists for the connected device. Every other device
|
||||
// is described by live presence from the heartbeat service (online /
|
||||
// offline within the missed-heartbeat window) when available, falling
|
||||
// back to the registry "last seen" hint when presence has no record.
|
||||
// The live *tag* on a multi-tag device is identified by route match (see
|
||||
// instanceMatchesActiveRoute), so per-instance liveness is correct.
|
||||
let liveStatus: MobileMacConnectionStatus? = isConnectedDevice ? store.macConnectionStatus : nil
|
||||
let presence: DeviceTreePresence? = store.presenceMap.deviceSummary(deviceId: device.deviceId)
|
||||
.map { $0.online ? .online : .offline(lastSeenAt: $0.lastSeenAt) }
|
||||
|
||||
Section {
|
||||
DeviceTreeDeviceRow(
|
||||
device: DeviceTreeDeviceSnapshot(
|
||||
deviceId: device.deviceId,
|
||||
title: device.title,
|
||||
platform: device.platform,
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
instanceCount: device.instances.count,
|
||||
isConnected: isConnectedDevice,
|
||||
liveStatus: liveStatus,
|
||||
presence: presence
|
||||
),
|
||||
isExpanded: expansion.isExpanded(deviceExpansionID(device)),
|
||||
setExpanded: { expanded in setExpanded(deviceExpansionID(device), expanded) }
|
||||
)
|
||||
|
||||
if expansion.isExpanded(deviceExpansionID(device)) {
|
||||
ForEach(device.instances) { instance in
|
||||
instanceRows(
|
||||
device: device,
|
||||
instance: instance,
|
||||
isConnectedDevice: isConnectedDevice
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func instanceRows(
|
||||
device: RegistryDevice,
|
||||
instance: RegistryAppInstance,
|
||||
isConnectedDevice: Bool
|
||||
) -> some View {
|
||||
let expansionID = instanceExpansionID(device: device, instance: instance)
|
||||
// Attribute the live workspace list to the ONE instance whose route
|
||||
// matches the live connection, not to every tag on the connected device.
|
||||
// The attach ticket carries no tag, so we identify the active build by
|
||||
// route identity (`activeRoute` endpoint ⊂ this instance's routes). A
|
||||
// multi-tag Mac therefore shows workspaces only under the build that is
|
||||
// actually connected; the other tags offer a Connect affordance instead
|
||||
// of (wrongly) mirroring another build's workspaces.
|
||||
let isActiveInstance = isConnectedDevice && instanceMatchesActiveRoute(instance)
|
||||
let allWorkspaces = isActiveInstance ? store.workspaces : []
|
||||
// The same shared row filter the flat list applies; the instance row's
|
||||
// workspace count keeps describing the build (all workspaces), only the
|
||||
// visible leaves narrow.
|
||||
let workspaces = allWorkspaces.filter { filter.matches($0) }
|
||||
let captured = DeviceTreeInstanceCapture(
|
||||
deviceId: device.deviceId,
|
||||
displayName: device.displayName,
|
||||
tag: instance.tag,
|
||||
routes: instance.routes
|
||||
private var removalDialogBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { pendingRemoval != nil },
|
||||
set: { presented in if !presented { pendingRemoval = nil } }
|
||||
)
|
||||
// No Connect affordance for the build that is already live; every other
|
||||
// route-bearing tag gets one.
|
||||
let connect = isActiveInstance ? nil : connectClosure(for: captured)
|
||||
}
|
||||
|
||||
DeviceTreeInstanceRow(
|
||||
instance: DeviceTreeInstanceSnapshot(
|
||||
tag: instance.tag,
|
||||
lastSeenAt: instance.lastSeenAt,
|
||||
hasRoutes: instance.hasRoutes,
|
||||
workspaceCount: allWorkspaces.count,
|
||||
isActiveInstance: isActiveInstance
|
||||
),
|
||||
isExpanded: expansion.isExpanded(expansionID),
|
||||
setExpanded: { expanded in setExpanded(expansionID, expanded) },
|
||||
connect: connect
|
||||
private func removeTitle(_ computer: MacComputerSnapshot?) -> String {
|
||||
String(
|
||||
format: L10n.string("mobile.computers.removeTitleFormat", defaultValue: "Remove %@?"),
|
||||
computer?.title ?? ""
|
||||
)
|
||||
|
||||
if expansion.isExpanded(expansionID) {
|
||||
if workspaces.isEmpty {
|
||||
if filter.isActive && !allWorkspaces.isEmpty {
|
||||
// The filter (not the build) emptied the leaves; offer the
|
||||
// shared way back instead of the connect placeholder.
|
||||
WorkspaceListFilterEmptyRow(filter: filter) { filter = .all }
|
||||
} else {
|
||||
DeviceTreeWorkspacePlaceholderRow(
|
||||
isActiveInstance: isActiveInstance,
|
||||
hasRoutes: instance.hasRoutes,
|
||||
connect: connect
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(workspaces) { workspace in
|
||||
WorkspaceNavigationRow(
|
||||
workspace: workspace,
|
||||
connectionStatus: store.macConnectionStatus,
|
||||
isSelected: false,
|
||||
navigationStyle: .sidebar,
|
||||
wrapWorkspaceTitles: displaySettings.wrapWorkspaceTitles,
|
||||
previewLineLimit: displaySettings.workspacePreviewLineCount,
|
||||
unreadIndicatorLeftShift: displaySettings.unreadIndicatorLeftShift,
|
||||
profilePictureLeftShift: displaySettings.profilePictureLeftShift,
|
||||
profilePictureSize: displaySettings.profilePictureSize,
|
||||
selectWorkspace: { id in
|
||||
selectWorkspace(id)
|
||||
dismiss()
|
||||
},
|
||||
renameWorkspace: nil,
|
||||
setPinned: nil
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 36, bottom: 4, trailing: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connect-on-tap closure for a non-connected instance. `nil` when the
|
||||
/// instance is the connected device's own running build (nothing to connect)
|
||||
/// or advertises no reachable route.
|
||||
private func connectClosure(for capture: DeviceTreeInstanceCapture) -> (() -> Void)? {
|
||||
guard capture.hasReachableRoute else { return nil }
|
||||
let store = store
|
||||
return {
|
||||
Task {
|
||||
await store.connectToRegistryInstance(
|
||||
device: RegistryDevice(
|
||||
deviceId: capture.deviceId,
|
||||
platform: "mac",
|
||||
displayName: capture.displayName,
|
||||
lastSeenAt: .distantPast,
|
||||
instances: []
|
||||
),
|
||||
instance: RegistryAppInstance(
|
||||
tag: capture.tag,
|
||||
routes: capture.routes,
|
||||
lastSeenAt: .distantPast
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
private func reload() async {
|
||||
// Load the local paired Macs first so the list has a fallback source the
|
||||
// instant it appears, then refresh from the registry.
|
||||
await store.loadPairedMacs()
|
||||
await store.loadRegistryDevices()
|
||||
}
|
||||
|
||||
/// Whether this instance is the build the live connection currently targets,
|
||||
/// matched by route identity (the live `activeRoute` endpoint appears in this
|
||||
/// instance's routes). Used to attribute the live workspace list to exactly
|
||||
/// one tag on a multi-tag device. Returns `false` when not connected or the
|
||||
/// live route is not a host/port endpoint.
|
||||
private func instanceMatchesActiveRoute(_ instance: RegistryAppInstance) -> Bool {
|
||||
guard store.connectionState == .connected,
|
||||
case let .hostPort(liveHost, livePort)? = store.activeRoute?.endpoint else {
|
||||
return false
|
||||
}
|
||||
let normalizedLiveHost = MobileShellRouteAuthPolicy.normalizedManualHost(liveHost) ?? liveHost
|
||||
return instance.routes.contains { route in
|
||||
guard case let .hostPort(host, port) = route.endpoint else { return false }
|
||||
let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) ?? host
|
||||
return normalizedHost == normalizedLiveHost && port == livePort
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceExpansionID(_ device: RegistryDevice) -> String {
|
||||
"device:\(device.deviceId)"
|
||||
}
|
||||
|
||||
private func instanceExpansionID(device: RegistryDevice, instance: RegistryAppInstance) -> String {
|
||||
"instance:\(device.deviceId):\(instance.tag)"
|
||||
}
|
||||
|
||||
private func setExpanded(_ id: String, _ expanded: Bool) {
|
||||
var store = expansion
|
||||
store.setExpanded(id, expanded)
|
||||
expandedStorage = store.storage
|
||||
}
|
||||
}
|
||||
|
||||
/// The immutable connect payload for one instance, captured out of the
|
||||
/// `@Observable` store so the row's action closure never holds a store reference.
|
||||
private struct DeviceTreeInstanceCapture {
|
||||
let deviceId: String
|
||||
let displayName: String?
|
||||
let tag: String
|
||||
let routes: [CmxAttachRoute]
|
||||
|
||||
var hasReachableRoute: Bool { !routes.isEmpty }
|
||||
}
|
||||
#endif
|
||||
|
||||
+50
-3
@@ -1,3 +1,4 @@
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileSupport
|
||||
import CmuxMobileWorkspace
|
||||
@@ -31,6 +32,12 @@ struct DisconnectedWorkspaceShellView: View {
|
||||
|
||||
@State private var showingSettings = false
|
||||
|
||||
/// Saved Macs restored/known on this device. Surfaced here so a returning or
|
||||
/// freshly-restored user can pick a known Mac directly instead of being
|
||||
/// dropped into the bare "add device" pairing flow when auto-reconnect did
|
||||
/// not land (e.g. the Mac is momentarily unreachable).
|
||||
private var savedMacs: [MobilePairedMac] { store?.pairedMacs ?? [] }
|
||||
|
||||
#if os(iOS)
|
||||
@State private var isShowingSetupHelp = false
|
||||
#endif
|
||||
@@ -39,11 +46,17 @@ struct DisconnectedWorkspaceShellView: View {
|
||||
NavigationStack {
|
||||
ContentUnavailableView {
|
||||
Label(
|
||||
L10n.string("mobile.devices.emptyTitle", defaultValue: "No devices"),
|
||||
savedMacs.isEmpty
|
||||
? L10n.string("mobile.devices.emptyTitle", defaultValue: "No devices")
|
||||
: L10n.string("mobile.devices.savedTitle", defaultValue: "Your Macs"),
|
||||
systemImage: "desktopcomputer.and.iphone"
|
||||
)
|
||||
} description: {
|
||||
Text(L10n.string("mobile.devices.emptyDescription", defaultValue: "Add a Mac to start syncing terminal workspaces."))
|
||||
Text(
|
||||
savedMacs.isEmpty
|
||||
? L10n.string("mobile.devices.emptyDescription", defaultValue: "Add a Mac to start syncing terminal workspaces.")
|
||||
: L10n.string("mobile.devices.savedDescription", defaultValue: "Tap a saved Mac to reconnect, or add another.")
|
||||
)
|
||||
} actions: {
|
||||
// When a paired Mac is unreachable and this device has no
|
||||
// active tailnet, lead with that explanation instead of
|
||||
@@ -55,8 +68,32 @@ struct DisconnectedWorkspaceShellView: View {
|
||||
.frame(maxWidth: 320, alignment: .leading)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
// Restored/known saved Macs, tappable to reconnect. A small fixed
|
||||
// set (bounded by the per-user backup cap), so a plain VStack is
|
||||
// fine; rows take value snapshots + a closure action, never the
|
||||
// store, honoring the list snapshot-boundary rule.
|
||||
if let store, !savedMacs.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(savedMacs) { mac in
|
||||
Button {
|
||||
Task { await store.switchToMac(macDeviceID: mac.macDeviceID) }
|
||||
} label: {
|
||||
Label(mac.displayName ?? mac.macDeviceID, systemImage: "desktopcomputer")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityIdentifier("MobileDisconnectedSavedMac-\(mac.macDeviceID)")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 320)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
Button(action: showAddDevice) {
|
||||
Text(L10n.string("mobile.addDevice.title", defaultValue: "Add device"))
|
||||
Text(
|
||||
savedMacs.isEmpty
|
||||
? L10n.string("mobile.addDevice.title", defaultValue: "Add device")
|
||||
: L10n.string("mobile.addDevice.another", defaultValue: "Add another Mac")
|
||||
)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.blue)
|
||||
@@ -91,6 +128,16 @@ struct DisconnectedWorkspaceShellView: View {
|
||||
#endif
|
||||
}
|
||||
.accessibilityIdentifier("MobileDisconnectedWorkspaceShell")
|
||||
.task {
|
||||
// Load (and, via the backup decorator, restore) saved Macs so a
|
||||
// known/restored Mac shows up here for one-tap reconnect. Only
|
||||
// auto-present the pairing sheet when there is nothing to pick,
|
||||
// so a returning user is not buried under the add-device flow.
|
||||
await store?.loadPairedMacs()
|
||||
if store?.pairedMacs.isEmpty ?? true {
|
||||
showAddDevice()
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $isShowingSetupHelp) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/// How a Mac's avatar icon should render: an SF Symbol or a literal emoji.
|
||||
enum MacAvatarIcon: Hashable {
|
||||
case symbol(String)
|
||||
case emoji(String)
|
||||
|
||||
/// Resolve from a user override, falling back to a default SF Symbol.
|
||||
static func resolve(custom: String?, defaultSymbol: String) -> MacAvatarIcon {
|
||||
guard let custom, !custom.isEmpty else { return .symbol(defaultSymbol) }
|
||||
if custom.unicodeScalars.contains(where: { $0.value > 127 }) { return .emoji(custom) }
|
||||
return .symbol(custom)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
#if os(iOS)
|
||||
import CMUXMobileCore
|
||||
import CmuxMobilePairedMac
|
||||
import CmuxMobileShell
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
/// Comprehensive per-computer detail + debug sheet, pushed from the Computers
|
||||
/// screen. This is a single detail view (not a recycled list row), so it holds
|
||||
/// the `@Bindable store` directly and pulls everything for one `macDeviceID`.
|
||||
///
|
||||
/// It deliberately separates the two facts the user needs to debug a connection:
|
||||
/// the PHONE's live connection to the Mac (can my phone reach it?) and the
|
||||
/// Durable Object presence (does the Mac say it is alive?), plus the exact routes
|
||||
/// the phone would dial. A "online via presence but phone not connected" split
|
||||
/// then points straight at a route/tailscale problem.
|
||||
struct MacComputerDetailView: View {
|
||||
@Bindable var store: CMUXMobileShellStore
|
||||
let macDeviceID: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var pendingRemoval = false
|
||||
@State private var editName = ""
|
||||
@State private var customColorPick = Color.blue
|
||||
@State private var customEmoji = ""
|
||||
@State private var didLoadEdits = false
|
||||
@State private var pendingCustomName: String?
|
||||
@State private var pendingCustomColor: String?
|
||||
@State private var pendingCustomIcon: String?
|
||||
|
||||
/// Curated icon choices: a few computer/utility SF Symbols + emojis.
|
||||
private static let symbolChoices = [
|
||||
"desktopcomputer", "macbook", "laptopcomputer", "server.rack",
|
||||
"terminal", "display", "bolt.fill", "star.fill", "heart.fill", "flame.fill",
|
||||
]
|
||||
private static let emojiChoices = ["💻", "🖥️", "⚡️", "🔥", "⭐️", "🚀", "🐧", "🍎", "🎮", "👾"]
|
||||
|
||||
private var pairedMac: MobilePairedMac? {
|
||||
store.pairedMacs.first { $0.macDeviceID == macDeviceID }
|
||||
}
|
||||
private var connectionStatus: MobileMacConnectionStatus? {
|
||||
store.macConnectionStatuses[macDeviceID]
|
||||
}
|
||||
private var presence: PresenceMap.DeviceSummary? {
|
||||
store.presenceMap.deviceSummary(deviceId: macDeviceID)
|
||||
}
|
||||
private var isForeground: Bool { store.connectedMacDeviceID == macDeviceID }
|
||||
private var workspaceCount: Int {
|
||||
store.workspaces.filter { $0.macDeviceID == macDeviceID }.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
appearanceSection
|
||||
connectionSection
|
||||
presenceSection
|
||||
routesSection
|
||||
identitySection
|
||||
actionsSection
|
||||
}
|
||||
.navigationTitle(pairedMac?.resolvedName ?? macDeviceID)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
guard !didLoadEdits else { return }
|
||||
didLoadEdits = true
|
||||
let mac = pairedMac
|
||||
pendingCustomName = mac?.customName
|
||||
pendingCustomColor = mac?.customColor
|
||||
pendingCustomIcon = mac?.customIcon
|
||||
editName = mac?.customName ?? ""
|
||||
if let hex = mac?.customColor, let color = Color(hexString: hex) {
|
||||
customColorPick = color
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"\(L10n.string("mobile.computers.removeTitlePrefix", defaultValue: "Remove")) \(pairedMac?.displayName ?? macDeviceID)?",
|
||||
isPresented: $pendingRemoval,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(L10n.string("mobile.computers.remove", defaultValue: "Remove"), role: .destructive) {
|
||||
let id = macDeviceID
|
||||
Task { await store.forgetMac(macDeviceID: id); await store.loadPairedMacs() }
|
||||
dismiss()
|
||||
}
|
||||
Button(L10n.string("mobile.common.cancel", defaultValue: "Cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(L10n.string("mobile.computers.removeMessage",
|
||||
defaultValue: "This computer and its workspaces stop appearing here. Pair it again to add it back."))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance editing
|
||||
|
||||
@ViewBuilder
|
||||
private var appearanceSection: some View {
|
||||
Section(L10n.string("mobile.computers.section.appearance", defaultValue: "Appearance")) {
|
||||
LabeledContent(L10n.string("mobile.computers.field.name", defaultValue: "Name")) {
|
||||
TextField(pairedMac?.displayName ?? macDeviceID, text: $editName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { applyName(editName) }
|
||||
.accessibilityIdentifier("MobileComputerNameField")
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(L10n.string("mobile.computers.field.color", defaultValue: "Color"))
|
||||
.font(.subheadline)
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
autoChip(isSelected: pendingCustomColor == nil) { applyColor(nil) }
|
||||
ForEach(Array(MachineAvatarColors.palettes.indices), id: \.self) { i in
|
||||
colorSwatch(index: i)
|
||||
}
|
||||
ColorPicker("", selection: $customColorPick, supportsOpacity: false)
|
||||
.labelsHidden()
|
||||
.onChange(of: customColorPick) { _, newColor in
|
||||
if let hex = newColor.hexString { applyColor(hex) }
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(L10n.string("mobile.computers.field.icon", defaultValue: "Icon"))
|
||||
.font(.subheadline)
|
||||
iconWrap
|
||||
TextField(
|
||||
L10n.string("mobile.computers.field.customEmoji", defaultValue: "Custom emoji…"),
|
||||
text: $customEmoji
|
||||
)
|
||||
.submitLabel(.done)
|
||||
.onSubmit {
|
||||
let trimmed = customEmoji.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { applyIcon(trimmed); customEmoji = "" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var iconWrap: some View {
|
||||
let symbols = Self.symbolChoices.map { MacAvatarIcon.symbol($0) }
|
||||
let emojis = Self.emojiChoices.map { MacAvatarIcon.emoji($0) }
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 6), spacing: 10) {
|
||||
autoChip(isSelected: pendingCustomIcon == nil) { applyIcon(nil) }
|
||||
ForEach(symbols + emojis, id: \.self) { icon in iconChip(icon) }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func iconChip(_ icon: MacAvatarIcon) -> some View {
|
||||
let value: String = { if case let .symbol(s) = icon { return s } else if case let .emoji(e) = icon { return e } else { return "" } }()
|
||||
let isSelected = pendingCustomIcon == value
|
||||
Button { applyIcon(value) } label: {
|
||||
Group {
|
||||
switch icon {
|
||||
case .symbol(let name): Image(systemName: name).font(.body)
|
||||
case .emoji(let emoji): Text(emoji).font(.body)
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12), in: Circle())
|
||||
.overlay(Circle().strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func colorSwatch(index: Int) -> some View {
|
||||
let isSelected = pendingCustomColor == "palette:\(index)"
|
||||
Button { applyColor("palette:\(index)") } label: {
|
||||
Circle()
|
||||
.fill(MachineAvatarColors.gradient(index: index))
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay(Circle().strokeBorder(isSelected ? Color.primary : .clear, lineWidth: 2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func autoChip(isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(L10n.string("mobile.computers.auto", defaultValue: "Auto"))
|
||||
.font(.caption.weight(.medium))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.12), in: Circle())
|
||||
.overlay(Circle().strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func applyName(_ name: String?) {
|
||||
let n = name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
pendingCustomName = (n?.isEmpty == false) ? n : nil
|
||||
persistCustomization()
|
||||
}
|
||||
|
||||
private func applyColor(_ color: String?) {
|
||||
pendingCustomColor = color
|
||||
persistCustomization()
|
||||
}
|
||||
|
||||
private func applyIcon(_ icon: String?) {
|
||||
pendingCustomIcon = icon
|
||||
persistCustomization()
|
||||
}
|
||||
|
||||
private func persistCustomization() {
|
||||
let name = pendingCustomName
|
||||
let color = pendingCustomColor
|
||||
let icon = pendingCustomIcon
|
||||
Task {
|
||||
await store.updateMacCustomization(
|
||||
macDeviceID: macDeviceID,
|
||||
customName: name,
|
||||
customColor: color,
|
||||
customIcon: icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectionSection: some View {
|
||||
Section(L10n.string("mobile.computers.section.connection", defaultValue: "Connection")) {
|
||||
LabeledContent(L10n.string("mobile.computers.field.phone", defaultValue: "This phone")) {
|
||||
Label(connectionPhrase, systemImage: "circle.fill")
|
||||
.labelStyle(.titleAndIcon)
|
||||
.foregroundStyle(connectionColor)
|
||||
.font(.callout)
|
||||
}
|
||||
if isForeground {
|
||||
LabeledContent(L10n.string("mobile.computers.field.role", defaultValue: "Role"),
|
||||
value: L10n.string("mobile.computers.role.foreground", defaultValue: "Active (foreground)"))
|
||||
}
|
||||
LabeledContent(L10n.string("mobile.computers.field.workspaces", defaultValue: "Workspaces"),
|
||||
value: "\(workspaceCount)")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var presenceSection: some View {
|
||||
Section {
|
||||
if let presence {
|
||||
LabeledContent(L10n.string("mobile.computers.field.reported", defaultValue: "Reports"),
|
||||
value: presence.online
|
||||
? L10n.string("mobile.deviceTree.online", defaultValue: "Online")
|
||||
: L10n.string("mobile.deviceTree.offline", defaultValue: "Offline"))
|
||||
if let buildLabel = presence.buildLabel {
|
||||
LabeledContent(
|
||||
L10n.string("mobile.computers.field.build", defaultValue: "Build"),
|
||||
value: buildLabel)
|
||||
}
|
||||
LabeledContent(L10n.string("mobile.computers.field.lastSeen", defaultValue: "Last seen"),
|
||||
value: presence.lastSeenAt.formatted(.relative(presentation: .named)))
|
||||
} else if connectionStatus == .connected {
|
||||
// No server heartbeat, but the phone is connected to this Mac right
|
||||
// now — so it IS online; the live connection is the liveness truth.
|
||||
// Lead with that instead of a bare "unknown"/"no heartbeat" that
|
||||
// contradicts the green Connection section. The clarifier explains
|
||||
// why there's no server record (presence heartbeat is currently a
|
||||
// dev-only feature; stable Macs don't announce it yet).
|
||||
LabeledContent(
|
||||
L10n.string("mobile.computers.field.reported", defaultValue: "Reports"),
|
||||
value: L10n.string("mobile.deviceTree.online", defaultValue: "Online"))
|
||||
LabeledContent(
|
||||
L10n.string("mobile.computers.field.source", defaultValue: "Source"),
|
||||
value: L10n.string(
|
||||
"mobile.computers.presenceViaConnection",
|
||||
defaultValue: "this phone's connection (no server heartbeat)"))
|
||||
} else {
|
||||
LabeledContent(L10n.string("mobile.computers.field.reported", defaultValue: "Reports"),
|
||||
value: L10n.string("mobile.computers.presenceUnknown", defaultValue: "unknown"))
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.string("mobile.computers.section.presence", defaultValue: "Presence (from server)"))
|
||||
} footer: {
|
||||
Text(L10n.string("mobile.computers.presenceFooter",
|
||||
defaultValue: "Presence is the Mac's own heartbeat to the presence service, which is currently a DEV-only feature. Stable cmux Macs don't announce it yet, so a Mac you're connected to may show no server heartbeat. If presence says online but This phone is not connected, the Mac is reachable elsewhere but not from your phone, usually a Tailscale or route problem."))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var routesSection: some View {
|
||||
Section {
|
||||
let routes = pairedMac?.routes ?? []
|
||||
if routes.isEmpty {
|
||||
Text(L10n.string("mobile.computers.noRoute", defaultValue: "no route"))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(routes.sorted { $0.priority > $1.priority }, id: \.id) { route in
|
||||
LabeledContent(route.kind.rawValue) {
|
||||
Text(endpointText(route.endpoint))
|
||||
.font(.callout.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(L10n.string("mobile.computers.section.routes", defaultValue: "Routes the phone can dial"))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var identitySection: some View {
|
||||
Section(L10n.string("mobile.computers.section.identity", defaultValue: "Identity")) {
|
||||
LabeledContent(L10n.string("mobile.computers.field.deviceId", defaultValue: "Device ID")) {
|
||||
Text(macDeviceID).font(.callout.monospaced()).foregroundStyle(.secondary)
|
||||
.lineLimit(1).truncationMode(.middle).textSelection(.enabled)
|
||||
}
|
||||
if let createdAt = pairedMac?.createdAt {
|
||||
LabeledContent(L10n.string("mobile.computers.field.pairedSince", defaultValue: "Paired since"),
|
||||
value: createdAt.formatted(.dateTime.month().day().year()))
|
||||
}
|
||||
if let lastSeenAt = pairedMac?.lastSeenAt {
|
||||
LabeledContent(L10n.string("mobile.computers.field.routeUpdated", defaultValue: "Route updated"),
|
||||
value: lastSeenAt.formatted(.relative(presentation: .named)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionsSection: some View {
|
||||
Section {
|
||||
Button {
|
||||
// Reconnect THIS computer, not whichever Mac is currently active:
|
||||
// `switchToMac` promotes a live secondary connection to this Mac or
|
||||
// re-dials it specifically. `reconnectOrRefresh()` would instead
|
||||
// refresh/redial the foreground/active Mac and leave the computer
|
||||
// shown here untouched.
|
||||
Task { await store.switchToMac(macDeviceID: macDeviceID) }
|
||||
} label: {
|
||||
Label(L10n.string("mobile.workspace.reconnect", defaultValue: "Reconnect"), systemImage: "arrow.clockwise")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
pendingRemoval = true
|
||||
} label: {
|
||||
Label(L10n.string("mobile.computers.remove", defaultValue: "Remove"), systemImage: "trash")
|
||||
}
|
||||
.accessibilityIdentifier("MobileComputerDetailRemove")
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPhrase: String {
|
||||
switch connectionStatus {
|
||||
case .connected: return L10n.string("mobile.deviceTree.connected", defaultValue: "Connected")
|
||||
case .reconnecting: return L10n.string("mobile.deviceTree.reconnecting", defaultValue: "Reconnecting…")
|
||||
case .unavailable, nil: return L10n.string("mobile.computers.notConnected", defaultValue: "Not connected")
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionColor: Color {
|
||||
switch connectionStatus {
|
||||
case .connected: return .green
|
||||
case .reconnecting: return .orange
|
||||
case .unavailable, nil: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func endpointText(_ endpoint: CmxAttachEndpoint) -> String {
|
||||
if case let .hostPort(host, port) = endpoint { return "\(host):\(port)" }
|
||||
return "—"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,171 @@
|
||||
#if os(iOS)
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// A computer (Mac/host) row on the Computers screen: a machine-colored avatar,
|
||||
/// the Mac's name, a primary line for the PHONE'S connection state + workspace
|
||||
/// count, and a diagnostic line for presence + route. The trailing dot reflects
|
||||
/// the phone's connection (green = the phone is talking to this Mac now).
|
||||
struct MacComputerRow: View {
|
||||
let computer: MacComputerSnapshot
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(avatarGradient)
|
||||
.frame(width: 40, height: 40)
|
||||
switch MacAvatarIcon.resolve(custom: computer.customIcon, defaultSymbol: platformSymbol) {
|
||||
case .symbol(let name):
|
||||
Image(systemName: name)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityHidden(true)
|
||||
case .emoji(let emoji):
|
||||
Text(emoji).font(.system(size: 20)).accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(computer.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
if let buildLabel = computer.buildLabel {
|
||||
buildBadge(buildLabel)
|
||||
}
|
||||
}
|
||||
Text(connectionLine)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(diagnosticLine)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
badge
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityIdentifier("MobileComputerRow-\(computer.deviceId)")
|
||||
}
|
||||
|
||||
/// The connection dot: green only when the PHONE is actually connected to this
|
||||
/// Mac. Orange while reconnecting, grey when the phone is not connected (even
|
||||
/// if presence says the Mac is online — that's the route/tailscale signal).
|
||||
@ViewBuilder
|
||||
private var badge: some View {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(dotColor)
|
||||
.accessibilityLabel(connectionPhrase)
|
||||
.accessibilityIdentifier("MobileComputerStatus-\(computer.deviceId)-\(isConnected ? "connected" : "disconnected")")
|
||||
}
|
||||
|
||||
/// A small build-channel pill (e.g. "DEV · teams", "Nightly"). DEV/RC/Staging
|
||||
/// are tinted orange (pre-release), Nightly blue, Stable secondary, so a glance
|
||||
/// tells you what kind of build a host runs.
|
||||
private func buildBadge(_ label: String) -> some View {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(buildBadgeTint(label).opacity(0.18), in: Capsule())
|
||||
.foregroundStyle(buildBadgeTint(label))
|
||||
.accessibilityLabel(
|
||||
"\(L10n.string("mobile.computers.buildLabelPrefix", defaultValue: "Build:")) \(label)")
|
||||
}
|
||||
|
||||
private func buildBadgeTint(_ label: String) -> Color {
|
||||
if label.hasPrefix("DEV") || label == "RC" || label == "Staging" { return .orange }
|
||||
if label == "Nightly" { return .blue }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
switch computer.connectionStatus {
|
||||
case .connected: return .green
|
||||
case .reconnecting: return .orange
|
||||
case .unavailable, nil: return .secondary.opacity(0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var isConnected: Bool { computer.connectionStatus == .connected }
|
||||
|
||||
private var avatarGradient: LinearGradient {
|
||||
MachineAvatarColors.gradient(
|
||||
customColor: computer.customColor,
|
||||
fallbackIndex: computer.colorIndex,
|
||||
machineID: computer.deviceId,
|
||||
fallbackID: computer.deviceId
|
||||
)
|
||||
}
|
||||
|
||||
private var platformSymbol: String {
|
||||
switch computer.platform.lowercased() {
|
||||
case "linux", "windows": return "server.rack"
|
||||
default: return "desktopcomputer"
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary line: the phone's connection to this Mac + workspace count.
|
||||
private var connectionLine: String {
|
||||
let count = L10n.terminalCountWorkspaces(computer.workspaceCount)
|
||||
return "\(connectionPhrase) · \(count)"
|
||||
}
|
||||
|
||||
private var connectionPhrase: String {
|
||||
switch computer.connectionStatus {
|
||||
case .connected:
|
||||
return L10n.string("mobile.deviceTree.connected", defaultValue: "Connected")
|
||||
case .reconnecting:
|
||||
return L10n.string("mobile.deviceTree.reconnecting", defaultValue: "Reconnecting…")
|
||||
case .unavailable, nil:
|
||||
return L10n.string("mobile.computers.notConnected", defaultValue: "Not connected")
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostic line: presence (the Mac's own heartbeat) + the route the phone
|
||||
/// would dial. Lets the user see "online via presence but phone not connected"
|
||||
/// (a tailscale/route problem) and the exact endpoint.
|
||||
///
|
||||
/// When the phone is CONNECTED to this Mac, the live connection is the liveness
|
||||
/// truth, so a server "presence: unknown" next to "Connected" is contradictory
|
||||
/// noise — drop it and show just the route. Real presence data (online / last
|
||||
/// seen) still shows, and the full presence state is always in the detail sheet.
|
||||
private var diagnosticLine: String {
|
||||
let route = computer.routeDescription ?? L10n.string("mobile.computers.noRoute", defaultValue: "no route")
|
||||
if isConnected, computer.presence == nil {
|
||||
return route
|
||||
}
|
||||
return String(
|
||||
format: L10n.string("mobile.computers.diagnosticFormat", defaultValue: "Presence: %@ · %@"),
|
||||
presencePhrase, route
|
||||
)
|
||||
}
|
||||
|
||||
private var presencePhrase: String {
|
||||
switch computer.presence {
|
||||
case .online:
|
||||
return L10n.string("mobile.deviceTree.online", defaultValue: "Online")
|
||||
case .offline(let lastSeenAt):
|
||||
return lastSeenLine(max(lastSeenAt, computer.lastSeenAt))
|
||||
case nil:
|
||||
return L10n.string("mobile.computers.presenceUnknown", defaultValue: "unknown")
|
||||
}
|
||||
}
|
||||
|
||||
private func lastSeenLine(_ lastSeenAt: Date) -> String {
|
||||
String(
|
||||
format: L10n.string("mobile.deviceTree.lastSeenFormat", defaultValue: "Last seen %@"),
|
||||
lastSeenAt.formatted(.relative(presentation: .named))
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,29 @@
|
||||
import CmuxMobileShellModel
|
||||
import Foundation
|
||||
|
||||
/// Immutable per-computer snapshot for the Computers screen.
|
||||
struct MacComputerSnapshot: Equatable, Identifiable {
|
||||
let deviceId: String
|
||||
let title: String
|
||||
let platform: String
|
||||
/// The Mac's distinct color index.
|
||||
var colorIndex: Int?
|
||||
/// User color override.
|
||||
var customColor: String?
|
||||
/// User icon override.
|
||||
var customIcon: String?
|
||||
/// The phone's live connection to this Mac.
|
||||
let connectionStatus: MobileMacConnectionStatus?
|
||||
/// Presence from the Durable Object presence worker.
|
||||
let presence: DeviceTreePresence?
|
||||
/// The host's build channel label from its heartbeat.
|
||||
var buildLabel: String?
|
||||
/// The reachable route the phone would dial.
|
||||
let routeDescription: String?
|
||||
/// When the Mac was last seen by the paired store.
|
||||
let lastSeenAt: Date
|
||||
/// How many aggregated workspaces this computer contributes.
|
||||
let workspaceCount: Int
|
||||
|
||||
var id: String { deviceId }
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import CmuxMobileShellModel
|
||||
import SwiftUI
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
/// The shared machine color palette: a deterministic gradient per owning Mac so a
|
||||
/// computer and all of its workspaces read with the same color across the
|
||||
/// workspace list and the Computers screen. The slot is derived in the model
|
||||
/// (``MachineAvatarPalette``); this maps it to concrete SwiftUI colors. Keep the
|
||||
/// entries visually distinct so adjacent Macs read apart.
|
||||
///
|
||||
struct MachineAvatarColors {
|
||||
static let palettes: [[Color]] = [
|
||||
[Color.blue, Color.cyan],
|
||||
[Color.green, Color.teal],
|
||||
[Color.orange, Color.yellow],
|
||||
[Color.purple, Color.indigo],
|
||||
[Color.pink, Color.red],
|
||||
[Color.mint, Color.green],
|
||||
[Color.indigo, Color.blue],
|
||||
[Color.brown, Color.orange],
|
||||
]
|
||||
|
||||
/// The gradient for a DISTINCT machine color index (from
|
||||
/// ``MobileWorkspaceAggregation/machineColorIndex``), wrapping at the palette
|
||||
/// size. This is the preferred path in the aggregated list: distinct Macs get
|
||||
/// distinct colors instead of occasionally colliding on a shared hash slot.
|
||||
static func gradient(index: Int) -> LinearGradient {
|
||||
let slot = ((index % palettes.count) + palettes.count) % palettes.count
|
||||
return LinearGradient(colors: palettes[slot], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
/// Fallback gradient keyed to a hash of `machineID` (or `fallbackID` when the
|
||||
/// machine is unknown). Used only where no assigned color index is available
|
||||
/// (a non-aggregated preview); the hash can collide, so prefer ``gradient(index:)``.
|
||||
static func gradient(machineID: String?, fallbackID: String) -> LinearGradient {
|
||||
let slot = MachineAvatarPalette(slotCount: palettes.count)
|
||||
.slot(machineID: machineID, fallbackID: fallbackID)
|
||||
return LinearGradient(colors: palettes[slot], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
/// Resolve a Mac's avatar gradient honoring its user override first:
|
||||
/// `"palette:<n>"` picks a built-in swatch, `"#RRGGBB"` a custom solid color;
|
||||
/// otherwise fall back to the assigned color index, then the id hash.
|
||||
static func gradient(
|
||||
customColor: String?,
|
||||
fallbackIndex: Int?,
|
||||
machineID: String?,
|
||||
fallbackID: String
|
||||
) -> LinearGradient {
|
||||
if let customColor, !customColor.isEmpty {
|
||||
if customColor.hasPrefix("palette:"),
|
||||
let n = Int(customColor.dropFirst("palette:".count)) {
|
||||
return gradient(index: n)
|
||||
}
|
||||
if let color = Color(hexString: customColor) {
|
||||
return LinearGradient(
|
||||
colors: [color, color.opacity(0.72)],
|
||||
startPoint: .topLeading, endPoint: .bottomTrailing
|
||||
)
|
||||
}
|
||||
}
|
||||
if let fallbackIndex { return gradient(index: fallbackIndex) }
|
||||
return gradient(machineID: machineID, fallbackID: fallbackID)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
/// Parse a `#RGB` / `#RRGGBB` / `#RRGGBBAA` hex string. `nil` when malformed.
|
||||
init?(hexString: String) {
|
||||
var hex = hexString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard hex.hasPrefix("#") else { return nil }
|
||||
hex.removeFirst()
|
||||
guard let value = UInt64(hex, radix: 16) else { return nil }
|
||||
let r, g, b, a: Double
|
||||
switch hex.count {
|
||||
case 3:
|
||||
r = Double((value >> 8) & 0xF) / 15
|
||||
g = Double((value >> 4) & 0xF) / 15
|
||||
b = Double(value & 0xF) / 15
|
||||
a = 1
|
||||
case 6:
|
||||
r = Double((value >> 16) & 0xFF) / 255
|
||||
g = Double((value >> 8) & 0xFF) / 255
|
||||
b = Double(value & 0xFF) / 255
|
||||
a = 1
|
||||
case 8:
|
||||
r = Double((value >> 24) & 0xFF) / 255
|
||||
g = Double((value >> 16) & 0xFF) / 255
|
||||
b = Double((value >> 8) & 0xFF) / 255
|
||||
a = Double(value & 0xFF) / 255
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
self = Color(.sRGB, red: r, green: g, blue: b, opacity: a)
|
||||
}
|
||||
|
||||
/// `#RRGGBB` for a resolved color, for persisting a custom color pick.
|
||||
var hexString: String? {
|
||||
#if canImport(UIKit)
|
||||
let ui = UIColor(self)
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
guard ui.getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil }
|
||||
func hexByte(_ component: CGFloat) -> String {
|
||||
let byte = min(255, max(0, Int((component * 255).rounded())))
|
||||
return "\(Self.hexDigits[byte >> 4])\(Self.hexDigits[byte & 0x0F])"
|
||||
}
|
||||
return "#\(hexByte(r))\(hexByte(g))\(hexByte(b))"
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private static let hexDigits = Array("0123456789ABCDEF")
|
||||
}
|
||||
+17
-1
@@ -1,12 +1,19 @@
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
/// A workspace-list row that surfaces a problem connection state (reconnecting
|
||||
/// or offline) above the workspaces, so the user can tell a healthy link from a
|
||||
/// recovering or dropped one.
|
||||
/// recovering or dropped one. When offline and a `reconnect` action is provided,
|
||||
/// it offers an explicit Reconnect button so a returning user whose auto-
|
||||
/// reconnect failed is never stranded on a list with no way to act (the
|
||||
/// integrated list stays the only surface — no separate picker screen).
|
||||
struct MobileMacConnectionStatusRow: View {
|
||||
let host: String
|
||||
let status: MobileMacConnectionStatus
|
||||
/// Manual reconnect for the offline (`.unavailable`) state. `nil` in previews
|
||||
/// and where reconnect is not applicable.
|
||||
var reconnect: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
@@ -28,6 +35,15 @@ struct MobileMacConnectionStatusRow: View {
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if status == .unavailable, let reconnect {
|
||||
Button(action: reconnect) {
|
||||
Text(L10n.string("mobile.workspace.reconnect", defaultValue: "Reconnect"))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.accessibilityIdentifier("MobileMacReconnectButton")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.accessibilityElement(children: .combine)
|
||||
|
||||
+35
-10
@@ -56,6 +56,7 @@ public final class MobilePushCoordinator {
|
||||
private struct PendingDeeplink {
|
||||
let workspaceId: String?
|
||||
let surfaceId: String?
|
||||
let macDeviceId: String?
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
@@ -191,8 +192,14 @@ public final class MobilePushCoordinator {
|
||||
/// Whether to show a banner while the app is foreground. Suppressed when the
|
||||
/// user is already viewing the terminal the notification is about.
|
||||
public func shouldPresentInForeground(workspaceId: String?, surfaceId: String?) -> Bool {
|
||||
shouldPresentInForeground(workspaceId: workspaceId, surfaceId: surfaceId, macDeviceId: nil)
|
||||
}
|
||||
|
||||
/// Whether to show a banner while the app is foreground, scoped to the Mac
|
||||
/// that sent the notification when the payload includes it.
|
||||
public func shouldPresentInForeground(workspaceId: String?, surfaceId: String?, macDeviceId: String?) -> Bool {
|
||||
guard let store, let workspaceId,
|
||||
store.selectedWorkspaceID?.rawValue == workspaceId else {
|
||||
store.selectedWorkspaceMatches(remoteWorkspaceID: workspaceId, macDeviceID: macDeviceId) else {
|
||||
return true
|
||||
}
|
||||
if let surfaceId {
|
||||
@@ -209,9 +216,16 @@ public final class MobilePushCoordinator {
|
||||
/// immediately in those states is what stranded users on the workspaces
|
||||
/// home screen.
|
||||
public func handleTap(workspaceId: String?, surfaceId: String?) {
|
||||
handleTap(workspaceId: workspaceId, surfaceId: surfaceId, macDeviceId: nil)
|
||||
}
|
||||
|
||||
/// Deep-link to the workspace/terminal a tapped notification refers to,
|
||||
/// using the sending Mac id to disambiguate duplicate Mac-local ids.
|
||||
public func handleTap(workspaceId: String?, surfaceId: String?, macDeviceId: String?) {
|
||||
pendingDeeplink = PendingDeeplink(
|
||||
workspaceId: workspaceId,
|
||||
surfaceId: surfaceId,
|
||||
macDeviceId: macDeviceId,
|
||||
createdAt: now()
|
||||
)
|
||||
applyPendingDeeplinkIfReady()
|
||||
@@ -235,10 +249,16 @@ public final class MobilePushCoordinator {
|
||||
// the tap is never spent on a selection that cannot navigate.
|
||||
let workspaceTarget: MobileWorkspacePreview.ID
|
||||
if let workspaceId = pending.workspaceId {
|
||||
workspaceTarget = MobileWorkspacePreview.ID(rawValue: workspaceId)
|
||||
guard store.workspaces.contains(where: { $0.id == workspaceTarget }) else { return }
|
||||
guard let resolved = store.workspaceID(
|
||||
matchingRemoteWorkspaceID: workspaceId,
|
||||
macDeviceID: pending.macDeviceId
|
||||
) else { return }
|
||||
workspaceTarget = resolved
|
||||
} else if let surfaceId = pending.surfaceId {
|
||||
guard let owner = store.workspaceID(containingSurfaceID: surfaceId) else { return }
|
||||
guard let owner = store.workspaceID(
|
||||
containingSurfaceID: surfaceId,
|
||||
macDeviceID: pending.macDeviceId
|
||||
) else { return }
|
||||
workspaceTarget = owner
|
||||
} else {
|
||||
pendingDeeplink = nil
|
||||
@@ -255,6 +275,7 @@ public final class MobilePushCoordinator {
|
||||
pendingDeeplink = PendingDeeplink(
|
||||
workspaceId: nil,
|
||||
surfaceId: surfaceId,
|
||||
macDeviceId: pending.macDeviceId,
|
||||
createdAt: pending.createdAt
|
||||
)
|
||||
return
|
||||
@@ -280,18 +301,22 @@ public final class MobilePushCoordinator {
|
||||
/// is parked in ``PendingNotificationDismissQueue`` and the store flushes it
|
||||
/// on its next successful (re)subscribe. With a store, the store's own
|
||||
/// enqueue-first send provides the same guarantee for a down channel.
|
||||
/// - Parameter notificationId: The stable id of the dismissed notification.
|
||||
/// For a remote push this is `request.identifier` (the `apns-collapse-id`),
|
||||
/// with `cmux.notificationId` as a fallback.
|
||||
public func handleDismiss(notificationId: String?) async {
|
||||
/// - Parameters:
|
||||
/// - notificationId: The stable id of the dismissed notification. For a
|
||||
/// remote push this is `request.identifier` (the `apns-collapse-id`),
|
||||
/// with `cmux.notificationId` as a fallback.
|
||||
/// - macDeviceId: The Mac that owns the notification, from the `cmux`
|
||||
/// payload. Missing older payloads route through the foreground Mac.
|
||||
public func handleDismiss(notificationId: String?, macDeviceId: String?) async {
|
||||
guard let notificationId else { return }
|
||||
let trimmed = notificationId.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
let mac = macDeviceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let store else {
|
||||
pendingDismissQueue.enqueue([trimmed])
|
||||
pendingDismissQueue.enqueue([trimmed], macDeviceID: mac?.isEmpty == false ? mac : nil)
|
||||
return
|
||||
}
|
||||
await store.dismissNotification(ids: [trimmed])
|
||||
await store.dismissNotification(ids: [trimmed], macDeviceID: mac?.isEmpty == false ? mac : nil)
|
||||
}
|
||||
|
||||
/// Handle a silent Mac→iOS dismiss push (the cold lane, fanned out to every
|
||||
|
||||
@@ -70,6 +70,37 @@ struct MobileSettingsView: View {
|
||||
))
|
||||
}
|
||||
|
||||
// Stack team switcher. Only shown when the user belongs to more than
|
||||
// one team. Rendered as an INLINE picker — each team is a row with a
|
||||
// checkmark on the current one — so every team is visible at a glance
|
||||
// and one tap switches (clearer than a menu/navigation push for a
|
||||
// small set). Selecting a team writes `selectedTeamID`, which the root
|
||||
// view observes to re-scope the team-bound surfaces (paired Macs,
|
||||
// presence, backup) to that team without dropping the live terminal.
|
||||
if authManager.availableTeams.count > 1 {
|
||||
Section {
|
||||
Picker(selection: teamSelection) {
|
||||
ForEach(authManager.availableTeams) { team in
|
||||
Text(team.displayName).tag(team.id as String?)
|
||||
}
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
.accessibilityIdentifier("MobileSettingsTeamPicker")
|
||||
} header: {
|
||||
Label(
|
||||
L10n.string("mobile.settings.team", defaultValue: "Team"),
|
||||
systemImage: "person.2"
|
||||
)
|
||||
} footer: {
|
||||
Text(L10n.string(
|
||||
"mobile.settings.teamFooter",
|
||||
defaultValue: "Switches which Stack team's Macs and devices this app shows."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden entirely when there is nothing to show (no connected
|
||||
// Mac, no store to switch with, no rescan), so the no-devices
|
||||
// screen's reuse of this sheet does not render an empty header.
|
||||
@@ -303,6 +334,21 @@ struct MobileSettingsView: View {
|
||||
!connectedHostName.isEmpty || store != nil || rescanQR != nil
|
||||
}
|
||||
|
||||
/// Drives the team Picker. Reads the EFFECTIVE current team (`resolvedTeamID`,
|
||||
/// which falls back to the first team when nothing is explicitly selected) so
|
||||
/// the picker always shows a concrete selection, and writes the user's choice
|
||||
/// to `selectedTeamID` (persisted; observed by the root for the lazy re-scope).
|
||||
private var teamSelection: Binding<String?> {
|
||||
Binding(
|
||||
get: { authManager.resolvedTeamID },
|
||||
set: { newValue in
|
||||
if let newValue, newValue != authManager.selectedTeamID {
|
||||
authManager.selectedTeamID = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var accountEmail: String {
|
||||
let email = authManager.currentUser?.primaryEmail?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let email, !email.isEmpty { return email }
|
||||
|
||||
+18
-13
@@ -27,19 +27,29 @@ extension MobileWorkspacePreview {
|
||||
}
|
||||
}
|
||||
|
||||
/// The default avatar symbol (per-workspace terminal count), used when the
|
||||
/// owning Mac has no custom icon.
|
||||
var avatarSymbolName: String {
|
||||
terminals.count > 1 ? "rectangle.stack.fill" : "terminal.fill"
|
||||
}
|
||||
|
||||
/// The avatar icon to render: the owning Mac's custom icon (SF Symbol or
|
||||
/// emoji) if set, else the default terminal-count symbol.
|
||||
var avatarIcon: MacAvatarIcon {
|
||||
MacAvatarIcon.resolve(custom: machineCustomIcon, defaultSymbol: avatarSymbolName)
|
||||
}
|
||||
|
||||
var avatarGradient: LinearGradient {
|
||||
let palettes: [[Color]] = [
|
||||
[Color.blue, Color.cyan],
|
||||
[Color.green, Color.teal],
|
||||
[Color.orange, Color.yellow],
|
||||
[Color.gray, Color.blue],
|
||||
]
|
||||
let colors = palettes[abs(stableAvatarSeed) % palettes.count]
|
||||
return LinearGradient(colors: colors, startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
// Color is keyed to the owning Mac so every workspace on the same machine —
|
||||
// and that Mac's row on the Computers screen — share one color. Honor the
|
||||
// user's custom color first, then the distinct per-Mac color index assigned
|
||||
// by the aggregation, then a hash of the id.
|
||||
MachineAvatarColors.gradient(
|
||||
customColor: machineCustomColor,
|
||||
fallbackIndex: machineColorIndex,
|
||||
machineID: macDeviceID,
|
||||
fallbackID: id.rawValue
|
||||
)
|
||||
}
|
||||
|
||||
/// The row's trailing slot: the connection problem when there is one,
|
||||
@@ -95,9 +105,4 @@ extension MobileWorkspacePreview {
|
||||
/// `.distantPast` (which buckets to `.none`, an empty trailing slot).
|
||||
private var latestActivityDate: Date { lastActivityAt ?? previewAt ?? .distantPast }
|
||||
|
||||
private var stableAvatarSeed: Int {
|
||||
id.rawValue.unicodeScalars.reduce(0) { partialResult, scalar in
|
||||
partialResult + Int(scalar.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#if os(iOS)
|
||||
import CmuxMobileShellModel
|
||||
import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
/// Shown over the terminal when the phone is NOT connected to the workspace's
|
||||
/// Mac, so a dropped or recovering connection reads as "reconnecting" with an
|
||||
/// action — never a silent black void (the recurring "black screen" report).
|
||||
/// Offline (`.unavailable`) offers an explicit Reconnect; `.reconnecting` shows
|
||||
/// progress. Nothing renders when connected (the caller gates on status).
|
||||
struct TerminalDisconnectedOverlay: View {
|
||||
let status: MobileMacConnectionStatus
|
||||
let host: String
|
||||
let reconnect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Sits over the (black) terminal; the white content is what the user
|
||||
// sees instead of an unexplained black screen.
|
||||
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
|
||||
VStack(spacing: 14) {
|
||||
if status == .reconnecting {
|
||||
ProgressView().controlSize(.large).tint(.white)
|
||||
} else {
|
||||
Image(systemName: status.symbolName)
|
||||
.font(.system(size: 42))
|
||||
.foregroundStyle(status.tintColor)
|
||||
}
|
||||
Text(status.label)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
if !host.isEmpty {
|
||||
Text(host)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
}
|
||||
if status == .unavailable {
|
||||
Button(action: reconnect) {
|
||||
Label(
|
||||
L10n.string("mobile.workspace.reconnect", defaultValue: "Reconnect"),
|
||||
systemImage: "arrow.clockwise"
|
||||
)
|
||||
.font(.body.weight(.semibold))
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color.accentColor, in: Capsule())
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 4)
|
||||
.accessibilityIdentifier("MobileTerminalReconnectButton")
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
}
|
||||
.accessibilityIdentifier("MobileTerminalDisconnectedOverlay")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+7
-7
@@ -22,13 +22,13 @@ struct WorkspaceDetailContainer: View {
|
||||
return store.selectedWorkspace
|
||||
}
|
||||
|
||||
/// Close-workspace closure for the detail top-bar menu. Present only when the
|
||||
/// connected Mac advertises `workspace.close.v1`, matching the workspace
|
||||
/// list's gating so the menu item stays hidden on older Macs. Built as an
|
||||
/// explicit closure literal (the compiler fails to type-check a
|
||||
/// method-reference ternary inside the large `WorkspaceDetailView` init).
|
||||
/// Close-workspace closure for the detail top-bar menu. Present only when
|
||||
/// this workspace's owning Mac advertises `workspace.close.v1`, matching the
|
||||
/// workspace list's row-scoped gating. Built as an explicit closure literal
|
||||
/// because the compiler fails to type-check a method-reference ternary
|
||||
/// inside the large `WorkspaceDetailView` init.
|
||||
private var closeWorkspaceClosure: ((MobileWorkspacePreview.ID) -> Void)? {
|
||||
guard store.supportsWorkspaceCloseActions else { return nil }
|
||||
guard workspace?.actionCapabilities.supportsCloseActions == true else { return nil }
|
||||
let store = store
|
||||
return { id in Task { await store.closeWorkspace(id: id) } }
|
||||
}
|
||||
@@ -37,7 +37,7 @@ struct WorkspaceDetailContainer: View {
|
||||
if let workspace {
|
||||
WorkspaceDetailView(
|
||||
host: store.connectedHostName,
|
||||
connectionStatus: store.macConnectionStatus,
|
||||
connectionStatus: workspace.macConnectionStatus ?? store.macConnectionStatus,
|
||||
workspace: workspace,
|
||||
store: store,
|
||||
createWorkspace: createWorkspace,
|
||||
|
||||
@@ -364,6 +364,24 @@ struct WorkspaceDetailView: View {
|
||||
.padding(.top, 10)
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
.overlay {
|
||||
// When the phone is not connected to this Mac, show a clear
|
||||
// reconnecting/offline state with an action instead of a black
|
||||
// terminal (the recurring "black screen" — a dropped connection left
|
||||
// the user staring at an unrendered surface).
|
||||
if connectionStatus != .connected {
|
||||
TerminalDisconnectedOverlay(status: connectionStatus, host: host) {
|
||||
Task {
|
||||
if let macDeviceID = workspace.macDeviceID,
|
||||
!macDeviceID.isEmpty,
|
||||
await store.switchToMac(macDeviceID: macDeviceID) {
|
||||
return
|
||||
}
|
||||
await store.reconnectOrRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS) && DEBUG
|
||||
// Store-side composer seam (DEBUG/UI-test only): exposes the source-of-truth
|
||||
// store flags that drive the surface's composer mirror, so a UI test can assert
|
||||
@@ -541,10 +559,9 @@ struct WorkspaceDetailView: View {
|
||||
.accessibilityIdentifier("MobileNewBrowserMenuItem")
|
||||
}
|
||||
|
||||
// Rename the current workspace from the terminal-icon menu, mirroring the
|
||||
// workspace list's rename action. Gated on the same capability the list
|
||||
// uses, so it stays hidden on older Macs.
|
||||
if store.supportsWorkspaceActions {
|
||||
// Rename the current workspace from the terminal-icon menu, mirroring
|
||||
// the workspace list's row-scoped capability gate.
|
||||
if workspace.actionCapabilities.supportsWorkspaceActions {
|
||||
Section {
|
||||
Button(action: presentRenameFromMenu) {
|
||||
Label(
|
||||
@@ -557,9 +574,8 @@ struct WorkspaceDetailView: View {
|
||||
}
|
||||
|
||||
// Mark the current workspace read/unread from the terminal-icon menu,
|
||||
// mirroring the workspace list's swipe action. Only when the Mac supports
|
||||
// read-state actions, so it stays hidden on older Macs.
|
||||
if store.supportsWorkspaceReadStateActions {
|
||||
// mirroring the workspace list's row-scoped capability gate.
|
||||
if workspace.actionCapabilities.supportsReadStateActions {
|
||||
Section {
|
||||
Button(action: toggleWorkspaceReadStateFromMenu) {
|
||||
Label(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/// A machine the workspace list can be filtered to.
|
||||
struct WorkspaceFilterMachine: Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
}
|
||||
+60
-15
@@ -3,21 +3,51 @@ import CmuxMobileSupport
|
||||
import SwiftUI
|
||||
|
||||
/// The one filter control shared by every surface that lists workspaces (the
|
||||
/// flat workspace list and the device tree): a toolbar menu picking a
|
||||
/// ``MobileWorkspaceListFilter`` case. New filter cases added to the model show
|
||||
/// up here automatically via `CaseIterable`; do not build a second per-surface
|
||||
/// filter menu.
|
||||
/// flat workspace list and the device tree): a toolbar menu with two orthogonal,
|
||||
/// composable dimensions — read state (All / Unread) and machine (multi-select)
|
||||
/// — so you can express e.g. "unread on Mac X and Mac Y". The machine section
|
||||
/// only appears when more than one machine is present, so single-Mac users see
|
||||
/// exactly the old All / Unread control.
|
||||
struct WorkspaceListFilterMenu: View {
|
||||
@Binding var filter: MobileWorkspaceListFilter
|
||||
/// Machines available to filter by. When fewer than two, the machine section
|
||||
/// is hidden (nothing to disambiguate).
|
||||
var machines: [WorkspaceFilterMachine] = []
|
||||
|
||||
private var showsMachineSection: Bool { machines.count > 1 }
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
Picker(
|
||||
L10n.string("mobile.workspaces.filter", defaultValue: "Filter"),
|
||||
selection: $filter
|
||||
L10n.string("mobile.workspaces.filter.readState", defaultValue: "Show"),
|
||||
selection: $filter.readState
|
||||
) {
|
||||
ForEach(MobileWorkspaceListFilter.allCases, id: \.self) { item in
|
||||
Text(item.displayName).tag(item)
|
||||
ForEach(MobileWorkspaceReadStateFilter.allCases, id: \.self) { state in
|
||||
Text(state.displayName).tag(state)
|
||||
}
|
||||
}
|
||||
|
||||
if showsMachineSection {
|
||||
Section(L10n.string("mobile.workspaces.filter.machines", defaultValue: "Machines")) {
|
||||
Button {
|
||||
filter.machines.removeAll()
|
||||
} label: {
|
||||
Label(
|
||||
L10n.string("mobile.workspaces.filter.allMachines", defaultValue: "All Machines"),
|
||||
systemImage: filter.machines.isEmpty ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
ForEach(machines) { machine in
|
||||
Button {
|
||||
filter.toggleMachine(machine.id)
|
||||
} label: {
|
||||
Label(
|
||||
machine.name,
|
||||
systemImage: filter.machines.contains(machine.id) ? "checkmark" : ""
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("MobileWorkspaceFilterMachine-\(machine.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -31,8 +61,8 @@ struct WorkspaceListFilterMenu: View {
|
||||
}
|
||||
}
|
||||
|
||||
extension MobileWorkspaceListFilter {
|
||||
/// The localized menu title for this filter case.
|
||||
extension MobileWorkspaceReadStateFilter {
|
||||
/// The localized menu title for this read-state option.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .all:
|
||||
@@ -41,18 +71,33 @@ extension MobileWorkspaceListFilter {
|
||||
return L10n.string("mobile.workspaces.filter.unread", defaultValue: "Unread")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MobileWorkspaceListFilter {
|
||||
/// The localized copy for "this filter hid every workspace". `nil` for the
|
||||
/// identity filter, which can never hide anything.
|
||||
/// identity filter, which can never hide anything. Reflects whichever
|
||||
/// dimension(s) are active.
|
||||
var emptyStateText: String? {
|
||||
switch self {
|
||||
case .all:
|
||||
return nil
|
||||
case .unread:
|
||||
guard isActive else { return nil }
|
||||
let machineScoped = !machines.isEmpty
|
||||
switch (readState, machineScoped) {
|
||||
case (.unread, true):
|
||||
return L10n.string(
|
||||
"mobile.workspaces.filter.empty.unreadOnMachines",
|
||||
defaultValue: "No unread workspaces on the selected machines"
|
||||
)
|
||||
case (.unread, false):
|
||||
return L10n.string(
|
||||
"mobile.workspaces.filter.empty.unread",
|
||||
defaultValue: "No unread workspaces"
|
||||
)
|
||||
case (.all, true):
|
||||
return L10n.string(
|
||||
"mobile.workspaces.filter.empty.machines",
|
||||
defaultValue: "No workspaces on the selected machines"
|
||||
)
|
||||
case (.all, false):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,29 @@ struct WorkspaceListView: View {
|
||||
/// previews), the menu is hidden.
|
||||
var rescanQR: (() -> Void)?
|
||||
var signOut: (() -> Void)?
|
||||
/// Manual reconnect for the offline status row. `nil` in previews.
|
||||
var reconnect: (() -> Void)?
|
||||
/// Present the add-device (pairing) flow from the Computers screen. `nil`
|
||||
/// hides the add affordance there.
|
||||
var showAddDevice: (() -> Void)?
|
||||
/// The shell store, forwarded to Settings to drive the multi-Mac switcher.
|
||||
/// `nil` in previews.
|
||||
var store: CMUXMobileShellStore?
|
||||
|
||||
/// Machines present in the (aggregated) workspace list, for the filter's
|
||||
/// machine multi-select. Single-machine yields no machine section. Names
|
||||
/// come from the device tree (registry or paired Macs), falling back to id.
|
||||
private var filterMachines: [WorkspaceFilterMachine] {
|
||||
let ids = MobileWorkspaceListFilter.machineIDs(in: workspaces)
|
||||
guard ids.count > 1 else { return [] }
|
||||
var names: [String: String] = [:]
|
||||
for device in store?.deviceTreeDevices ?? [] {
|
||||
if let name = device.displayName, !name.isEmpty {
|
||||
names[device.deviceId] = name
|
||||
}
|
||||
}
|
||||
return ids.map { WorkspaceFilterMachine(id: $0, name: names[$0] ?? $0) }
|
||||
}
|
||||
/// Optional: rename a workspace on the Mac. When present, each row offers a
|
||||
/// Rename context-menu action.
|
||||
var renameWorkspace: ((MobileWorkspacePreview.ID, String) -> Void)?
|
||||
@@ -129,7 +149,7 @@ struct WorkspaceListView: View {
|
||||
List {
|
||||
if connectionStatus != .connected {
|
||||
Section {
|
||||
MobileMacConnectionStatusRow(host: host, status: connectionStatus)
|
||||
MobileMacConnectionStatusRow(host: host, status: connectionStatus, reconnect: reconnect)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12))
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
@@ -151,6 +171,14 @@ struct WorkspaceListView: View {
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.workspaceListRefreshable(refresh)
|
||||
.onChange(of: MobileWorkspaceListFilter.machineIDs(in: workspaces)) { _, present in
|
||||
// Drop machine filters whose Mac left the aggregated list (a secondary
|
||||
// Mac disconnected, or the list fell below two machines so the filter
|
||||
// menu's machine section hid). Otherwise a stale machine id rejects
|
||||
// every row and strands the user on a blank list with no visible
|
||||
// control to clear the filter.
|
||||
filter.pruneMachines(notIn: present)
|
||||
}
|
||||
.navigationTitle(L10n.string("mobile.workspaces.title", defaultValue: "Workspaces"))
|
||||
.mobileInlineNavigationTitle()
|
||||
.searchable(text: $searchText)
|
||||
@@ -165,12 +193,12 @@ struct WorkspaceListView: View {
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
WorkspaceListFilterMenu(filter: $filter)
|
||||
WorkspaceListFilterMenu(filter: $filter, machines: filterMachines)
|
||||
newWorkspaceButton
|
||||
}
|
||||
#else
|
||||
ToolbarItemGroup {
|
||||
WorkspaceListFilterMenu(filter: $filter)
|
||||
WorkspaceListFilterMenu(filter: $filter, machines: filterMachines)
|
||||
newWorkspaceButton
|
||||
}
|
||||
#endif
|
||||
@@ -194,7 +222,7 @@ struct WorkspaceListView: View {
|
||||
// leaving a parent sheet covering it.
|
||||
.sheet(isPresented: $showingDeviceTree) {
|
||||
if let store {
|
||||
DeviceTreeView(store: store, selectWorkspace: selectWorkspace)
|
||||
DeviceTreeView(store: store, selectWorkspace: selectWorkspace, showAddDevice: showAddDevice)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -205,9 +233,9 @@ struct WorkspaceListView: View {
|
||||
Button {
|
||||
showingDeviceTree = true
|
||||
} label: {
|
||||
Image(systemName: "rectangle.stack")
|
||||
Image(systemName: "desktopcomputer")
|
||||
}
|
||||
.accessibilityLabel(L10n.string("mobile.settings.devices", defaultValue: "Devices"))
|
||||
.accessibilityLabel(L10n.string("mobile.computers.title", defaultValue: "Computers"))
|
||||
.accessibilityIdentifier("MobileWorkspaceDevicesButton")
|
||||
}
|
||||
#endif
|
||||
@@ -248,9 +276,10 @@ struct WorkspaceListView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func workspaceRow(_ workspace: MobileWorkspacePreview, indented: Bool) -> some View {
|
||||
let capabilities = workspace.actionCapabilities
|
||||
WorkspaceNavigationRow(
|
||||
workspace: workspace,
|
||||
connectionStatus: connectionStatus,
|
||||
connectionStatus: workspace.macConnectionStatus ?? connectionStatus,
|
||||
isSelected: navigationStyle == .sidebar && selectedWorkspaceID == workspace.id,
|
||||
navigationStyle: navigationStyle,
|
||||
wrapWorkspaceTitles: wrapWorkspaceTitles,
|
||||
@@ -259,14 +288,14 @@ struct WorkspaceListView: View {
|
||||
profilePictureLeftShift: profilePictureLeftShift,
|
||||
profilePictureSize: profilePictureSize,
|
||||
selectWorkspace: selectWorkspace,
|
||||
renameWorkspace: renameWorkspace,
|
||||
setPinned: setPinned,
|
||||
setUnread: setUnread,
|
||||
closeWorkspace: requestWorkspaceClose,
|
||||
renameWorkspace: capabilities.supportsWorkspaceActions ? renameWorkspace : nil,
|
||||
setPinned: capabilities.supportsWorkspaceActions ? setPinned : nil,
|
||||
setUnread: capabilities.supportsReadStateActions ? setUnread : nil,
|
||||
closeWorkspace: capabilities.supportsCloseActions ? requestWorkspaceClose : nil,
|
||||
isConfirmingClose: closeConfirmationBinding(for: workspace.id),
|
||||
confirmCloseWorkspace: closeWorkspace == nil ? nil : { _ in
|
||||
confirmCloseWorkspace: capabilities.supportsCloseActions && closeWorkspace != nil ? { _ in
|
||||
confirmCloseWorkspace()
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: indented ? 32 : 12, bottom: 4, trailing: 12))
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
@@ -102,10 +102,17 @@ struct WorkspaceAvatar: View {
|
||||
.fill(workspace.avatarGradient)
|
||||
.frame(width: CGFloat(size), height: CGFloat(size))
|
||||
|
||||
Image(systemName: workspace.avatarSymbolName)
|
||||
.font(.system(size: CGFloat(size) * 0.38, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityHidden(true)
|
||||
switch workspace.avatarIcon {
|
||||
case .symbol(let name):
|
||||
Image(systemName: name)
|
||||
.font(.system(size: CGFloat(size) * 0.38, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityHidden(true)
|
||||
case .emoji(let emoji):
|
||||
Text(emoji)
|
||||
.font(.system(size: CGFloat(size) * 0.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.offset(x: -CGFloat(leftShift))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import AppKit
|
||||
struct WorkspaceShellView: View {
|
||||
@Bindable var store: CMUXMobileShellStore
|
||||
let signOut: () -> Void
|
||||
/// Present the add-device (pairing) flow from the Computers screen. `nil`
|
||||
/// hides the add affordance.
|
||||
var showAddDevice: (() -> Void)?
|
||||
@Environment(MobileDisplaySettings.self) private var displaySettings
|
||||
@State private var compactNavigationPath: [MobileWorkspacePreview.ID] = []
|
||||
@State private var pendingCompactCreateNavigationWorkspaceIDs: Set<MobileWorkspacePreview.ID>?
|
||||
@@ -34,6 +37,10 @@ struct WorkspaceShellView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
layoutContent
|
||||
}
|
||||
|
||||
private var layoutContent: some View {
|
||||
Group {
|
||||
if usesCompactStack {
|
||||
stackLayout
|
||||
@@ -85,6 +92,8 @@ struct WorkspaceShellView: View {
|
||||
refresh: refreshWorkspacesClosure,
|
||||
rescanQR: { store.disconnectAndForgetActiveMac() },
|
||||
signOut: signOut,
|
||||
reconnect: reconnectClosure,
|
||||
showAddDevice: showAddDevice,
|
||||
store: store,
|
||||
renameWorkspace: renameWorkspaceClosure,
|
||||
setPinned: setWorkspacePinnedClosure,
|
||||
@@ -167,6 +176,8 @@ struct WorkspaceShellView: View {
|
||||
refresh: refreshWorkspacesClosure,
|
||||
rescanQR: { store.disconnectAndForgetActiveMac() },
|
||||
signOut: signOut,
|
||||
reconnect: reconnectClosure,
|
||||
showAddDevice: showAddDevice,
|
||||
store: store,
|
||||
renameWorkspace: renameWorkspaceClosure,
|
||||
setPinned: setWorkspacePinnedClosure,
|
||||
@@ -209,31 +220,28 @@ struct WorkspaceShellView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename/pin closures, present only when the connected Mac advertises the
|
||||
/// `workspace.actions.v1` capability so the row affordances stay hidden on
|
||||
/// older Macs that lack the handler. Built as explicit closure literals (not
|
||||
/// a method-reference ternary, which the compiler fails to type-check inside
|
||||
/// the large `WorkspaceListView` initializer).
|
||||
/// Workspace action closures, always present for the real store. Row and
|
||||
/// detail affordances gate themselves on each workspace's owning-Mac
|
||||
/// capability snapshot, so a secondary Mac is not hidden behind the
|
||||
/// foreground Mac's advertised capabilities. Built as explicit closure
|
||||
/// literals (not method-reference ternaries, which the compiler fails to
|
||||
/// type-check inside the large `WorkspaceListView` initializer).
|
||||
private var renameWorkspaceClosure: ((MobileWorkspacePreview.ID, String) -> Void)? {
|
||||
guard store.supportsWorkspaceActions else { return nil }
|
||||
let store = store
|
||||
return { id, title in Task { await store.renameWorkspace(id: id, title: title) } }
|
||||
}
|
||||
|
||||
private var setWorkspacePinnedClosure: ((MobileWorkspacePreview.ID, Bool) -> Void)? {
|
||||
guard store.supportsWorkspaceActions else { return nil }
|
||||
let store = store
|
||||
return { id, pinned in Task { await store.setWorkspacePinned(id: id, pinned) } }
|
||||
}
|
||||
|
||||
private var setWorkspaceUnreadClosure: ((MobileWorkspacePreview.ID, Bool) -> Void)? {
|
||||
guard store.supportsWorkspaceReadStateActions else { return nil }
|
||||
let store = store
|
||||
return { id, unread in Task { await store.setWorkspaceUnread(id: id, unread) } }
|
||||
}
|
||||
|
||||
private var closeWorkspaceClosure: ((MobileWorkspacePreview.ID) -> Void)? {
|
||||
guard store.supportsWorkspaceCloseActions else { return nil }
|
||||
let store = store
|
||||
return { id in Task { await store.closeWorkspace(id: id) } }
|
||||
}
|
||||
@@ -244,7 +252,15 @@ struct WorkspaceShellView: View {
|
||||
/// reference) is what crosses into the `List`-hosting view.
|
||||
private var refreshWorkspacesClosure: @Sendable () async -> Void {
|
||||
let store = store
|
||||
return { await store.refreshWorkspaces() }
|
||||
// Reconnect-or-refresh: when offline, pull-to-refresh re-attempts the saved
|
||||
// active Mac instead of no-opping, so the offline list can recover itself.
|
||||
return { await store.reconnectOrRefresh() }
|
||||
}
|
||||
|
||||
/// Manual reconnect for the offline status row's Reconnect button.
|
||||
private var reconnectClosure: () -> Void {
|
||||
let store = store
|
||||
return { Task { await store.reconnectOrRefresh() } }
|
||||
}
|
||||
|
||||
/// Group collapse/expand closure. Present when the Mac advertises
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import Testing
|
||||
@testable import CmuxMobileShellUI
|
||||
|
||||
/// Tests the pure expansion-state codec the device tree persists via
|
||||
/// `@AppStorage`, so the device → tag open/closed shape survives relaunch.
|
||||
@Suite struct DeviceTreeExpansionStoreTests {
|
||||
@Test func roundTripsThroughStorageString() {
|
||||
var store = DeviceTreeExpansionStore()
|
||||
store.setExpanded("device:a", true)
|
||||
store.setExpanded("instance:a:stable", true)
|
||||
let restored = DeviceTreeExpansionStore(storage: store.storage)
|
||||
#expect(restored.isExpanded("device:a"))
|
||||
#expect(restored.isExpanded("instance:a:stable"))
|
||||
#expect(!restored.isExpanded("device:b"))
|
||||
}
|
||||
|
||||
@Test func collapsingRemovesFromStorage() {
|
||||
var store = DeviceTreeExpansionStore(expandedIDs: ["device:a", "device:b"])
|
||||
store.setExpanded("device:a", false)
|
||||
#expect(!store.isExpanded("device:a"))
|
||||
#expect(store.isExpanded("device:b"))
|
||||
// Stable, sorted serialization so equal sets always encode identically.
|
||||
#expect(store.storage == "device:b")
|
||||
}
|
||||
|
||||
@Test func blankStorageDecodesToNoExpansion() {
|
||||
#expect(DeviceTreeExpansionStore(storage: "").expandedIDs.isEmpty)
|
||||
#expect(DeviceTreeExpansionStore(storage: "\n \n").expandedIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,24 @@ import Foundation
|
||||
|
||||
/// Mobile integration settings for pairing and syncing with cmux on iOS.
|
||||
public struct MobileCatalogSection: SettingCatalogSection {
|
||||
/// Mac-side iOS pairing host. Defaults off so macOS never asks for Local
|
||||
/// Network permission until the user opts in from Settings.
|
||||
/// Mac-side iOS pairing host. Release defaults OFF so macOS never asks for
|
||||
/// Local Network permission until the user opts in from Settings. DEBUG
|
||||
/// (dev) builds default ON so a dev Mac advertises its attach route without a
|
||||
/// manual Settings toggle — this is what lets a fresh dev iOS build discover
|
||||
/// the Mac automatically (see MacPairedMacBackupPublisher). An explicit user
|
||||
/// toggle still wins on either build.
|
||||
public let iOSPairingHost = DefaultsKey<Bool>(
|
||||
id: "mobile.iOSPairingHost.enabled",
|
||||
defaultValue: false,
|
||||
defaultValue: Self.iOSPairingHostDefault,
|
||||
userDefaultsKey: "mobile.iOSPairingHost.enabled"
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
private static let iOSPairingHostDefault = true
|
||||
#else
|
||||
private static let iOSPairingHostDefault = false
|
||||
#endif
|
||||
|
||||
/// TCP port the Mac-side iOS pairing listener prefers to bind.
|
||||
///
|
||||
/// This is a *preference*: if the port is already in use the listener
|
||||
|
||||
@@ -5,24 +5,6 @@ bool ghostty_surface_clear_selection(void *surface) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ghostty_surface_select_screen_rows(void *surface,
|
||||
unsigned int top_y,
|
||||
unsigned int bottom_y) {
|
||||
(void)surface;
|
||||
(void)top_y;
|
||||
(void)bottom_y;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ghostty_surface_selection_screen_rows(void *surface,
|
||||
unsigned int *top_y,
|
||||
unsigned int *bottom_y) {
|
||||
(void)surface;
|
||||
(void)top_y;
|
||||
(void)bottom_y;
|
||||
return false;
|
||||
}
|
||||
|
||||
void ghostty_config_diagnostics_count(void) {}
|
||||
void ghostty_config_get_diagnostic(void) {}
|
||||
void ghostty_string_free(void) {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user