From 9991f49e7a91c88fdcbdf881ee7f415c08c4563c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:14:10 -0700 Subject: [PATCH 01/11] =?UTF-8?q?iOS:=20hierarchical=20device=20tree=20(de?= =?UTF-8?q?vice=20=E2=86=92=20tags=20=E2=86=92=20workspaces)=20over=20the?= =?UTF-8?q?=20device=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render the merged #5626 device registry as a hierarchical tree: each registered device (Mac/host) expands to its cmux app instances (tags), and a tag expands to that build's workspaces; tapping a workspace opens it via the existing path. Surfaces the registry list to the UI (DeviceRegistryRefreshing.listDevices), adds a RegistryDevice/RegistryAppInstance value model, store.registryDevices + loadRegistryDevices + connectToRegistryInstance (connect-on-tap a non-connected tag via its routes), and a DeviceTreeView reachable from Settings. Keeps the flat workspace list and the multi-Mac switcher as the fallback paths. Online state: the connected device shows live macConnectionStatus; others show registry last-seen (best-effort, no per-host ping yet; the attach ticket carries no tag, so per-tag liveness is a TODO). Expansion persists via @AppStorage. Localized en+ja. Pure decode + expansion-codec tests. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryRefreshing.swift | 12 + .../DeviceRegistryService.swift | 96 +++++ .../MobileShellComposite.swift | 127 ++++++ .../DeviceRegistryListParsingTests.swift | 124 ++++++ .../CmuxMobileShellModel/RegistryDevice.swift | 95 +++++ .../DeviceTreeExpansionStore.swift | 44 +++ .../CmuxMobileShellUI/DeviceTreeRows.swift | 216 +++++++++++ .../CmuxMobileShellUI/DeviceTreeView.swift | 239 ++++++++++++ .../MobileSettingsView.swift | 22 ++ .../CmuxMobileShellUI/WorkspaceListView.swift | 3 +- .../DeviceTreeExpansionStoreTests.swift | 30 ++ .../Sources/CmuxMobileSupport/L10n.swift | 15 + ios/cmux/Resources/Localizable.xcstrings | 364 +++++++++++++++--- 13 files changed, 1323 insertions(+), 64 deletions(-) create mode 100644 Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift create mode 100644 Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift create mode 100644 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeExpansionStore.swift create mode 100644 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift create mode 100644 Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift create mode 100644 Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/DeviceTreeExpansionStoreTests.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift index 38c98727075c..918c332830c0 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift @@ -1,4 +1,5 @@ public import CMUXMobileCore +public import CmuxMobileShellModel /// A best-effort lookup of fresher attach routes for a paired Mac from the /// team-scoped device registry. @@ -21,4 +22,15 @@ public protocol DeviceRegistryRefreshing: Sendable { /// `nil` and `[]` are both treated as "no fresher routes" by /// ``DeviceRegistryService/selectReconnectRoutes(local:registry:)``. func freshRoutes(forMacDeviceID macDeviceID: String) async -> [CmxAttachRoute]? + + /// List the team's registered devices and their running cmux app instances, + /// for the device tree (device → tags → workspaces). + /// + /// The same team-scoped `GET /api/devices` response that backs + /// ``freshRoutes(forMacDeviceID:)``, decoded into the full two-level model + /// rather than narrowed to one Mac's routes. Best-effort like the rest of the + /// registry: returns `nil` when the registry is unreachable, the call is + /// unauthorized, or the response is malformed, so the tree falls back to the + /// locally known paired Macs and the app keeps working with the cloud down. + func listDevices() async -> [RegistryDevice]? } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index de2debb3cd7f..a01759ad34a9 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -1,4 +1,5 @@ public import CMUXMobileCore +public import CmuxMobileShellModel public import Foundation import os @@ -154,8 +155,103 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return Self.routes(forMacDeviceID: macDeviceID, in: data) } + public func listDevices() async -> [RegistryDevice]? { + guard let request = await makeRequest(method: "GET", path: "/api/devices", body: nil) else { + return nil + } + let data: Data + do { + let (responseData, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + return nil + } + data = responseData + } catch { + deviceRegistryLog.debug("listDevices request failed: \(String(describing: error), privacy: .public)") + return nil + } + return Self.parseDeviceList(in: data) + } + // MARK: - Parsing (pure, testable) + /// Decode the `/api/devices` list response into the full two-level device + /// tree (devices → app instances), for the device tree UI. Returns `nil` only + /// when the top-level envelope is undecodable; individual bad routes are + /// dropped (not fatal) so one malformed sibling can't blank the whole tree. + /// + /// Each route is decoded *failably* and individually (same forward-compat + /// contract as ``routes(forMacDeviceID:in:)``): a malformed or unknown-kind + /// route is skipped rather than failing its instance, so an old client stays + /// forward-compatible when a newer build advertises a route kind it cannot + /// decode. `lastSeenAt` is parsed leniently (ISO8601, with or without + /// fractional seconds), defaulting to ``Date/distantPast`` when absent so a + /// device still renders, just sorted oldest. + static func parseDeviceList(in data: Data) -> [RegistryDevice]? { + struct FailableRoute: Decodable { + let value: CmxAttachRoute? + init(from decoder: Decoder) throws { + value = try? CmxAttachRoute(from: decoder) + } + } + struct Instance: Decodable { + let tag: String? + let routes: [FailableRoute]? + let lastSeenAt: String? + } + struct Device: Decodable { + let deviceId: String + let platform: String? + let displayName: String? + let lastSeenAt: String? + let instances: [Instance]? + } + struct ListResponse: Decodable { + let devices: [Device] + } + guard let decoded = try? JSONDecoder().decode(ListResponse.self, from: data) else { + return nil + } + return decoded.devices.compactMap { device -> RegistryDevice? in + let deviceId = device.deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !deviceId.isEmpty else { return nil } + let instances = (device.instances ?? []).map { instance in + RegistryAppInstance( + tag: instance.tag?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? instance.tag! : "default", + routes: (instance.routes ?? []).compactMap(\.value), + lastSeenAt: Self.parseTimestamp(instance.lastSeenAt) + ) + } + return RegistryDevice( + deviceId: deviceId, + platform: device.platform?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + ? device.platform! : "mac", + displayName: device.displayName, + lastSeenAt: Self.parseTimestamp(device.lastSeenAt), + instances: instances + ) + } + } + + /// Lenient ISO8601 parse for the registry's `lastSeenAt` strings. The server + /// emits `Date.toISOString()` (always fractional seconds), but tolerate the + /// non-fractional form too. An absent/unparseable value yields + /// ``Date/distantPast`` so the device still renders rather than being dropped. + /// + /// The formatters are created per call rather than cached in a `static` so + /// this stays `Sendable`-clean under strict concurrency (`ISO8601DateFormatter` + /// is not `Sendable`). This runs once per `/api/devices` response, not on any + /// hot path, so the allocation is negligible. + static func parseTimestamp(_ value: String?) -> Date { + guard let value, !value.isEmpty else { return .distantPast } + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: value) { return date } + if let date = ISO8601DateFormatter().date(from: value) { return date } + return .distantPast + } + /// Decode the `/api/devices` list response and return the routes for the /// device whose id matches `macDeviceID`, preferring its most recently seen /// app instance. Returns `nil` when the device or routes are absent so the diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 6605a6e1b892..d6a1c21657a5 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -445,6 +445,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // Drop the cached paired Macs so the next signed-in user never sees the // previous user's hosts in the switcher. pairedMacs = [] + // Likewise drop the registry-backed device tree so a shared device never + // shows the previous user's team devices after sign-out. + registryDevices = [] // Reset the in-memory restoring flags; hasKnownPairedMac stays driven by // the forget path. On a real account switch the next reconnect's no-mac // branch clears the hint. Bump the reconnect generation so any in-flight @@ -1065,6 +1068,130 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + // MARK: - Device registry tree + + /// The team's registered devices and their cmux app instances (tags), for the + /// device tree (device → tags → workspaces). Fetched from the team-scoped + /// device registry via ``loadRegistryDevices()``. Empty until the first load, + /// when the registry is unreachable, or after sign-out. Best-effort: a + /// registry outage leaves this empty and the UI falls back to the locally + /// known paired Macs, so the tree degrades to the same hosts the switcher + /// shows rather than going blank. + public private(set) var registryDevices: [RegistryDevice] = [] + + /// The cmux device id of the Mac the live connection currently targets, or + /// `nil` when not connected. Used by the device tree to mark which device row + /// is live. Derived from the active attach ticket's `macDeviceID`; a manual + /// (`manual-…`) ticket has no real device id, so it does not correlate to a + /// registry row and yields `nil` (the tree then shows no device as connected, + /// which is honest for a manual host that is not in the registry). + public var connectedMacDeviceID: String? { + guard connectionState == .connected, + let macDeviceID = activeTicket?.macDeviceID, + !macDeviceID.isEmpty, + !macDeviceID.hasPrefix("manual-") else { + return nil + } + return macDeviceID + } + + /// Reload ``registryDevices`` from the team-scoped device registry. + /// + /// Best-effort and failure-tolerant: a missing registry, an unauthorized + /// call, or a malformed response leaves the current list untouched (so a + /// transient blip never blanks a populated tree). Devices are sorted with the + /// currently-connected one first, then by most-recently-seen, so the tree + /// leads with the host the user is on. Mirrors ``loadPairedMacs()``: signed + /// out yields an empty list. + public func loadRegistryDevices() async { + guard isSignedIn, let deviceRegistry else { + registryDevices = [] + return + } + guard let loaded = await deviceRegistry.listDevices() else { + // nil == registry unavailable/unauthorized/malformed: keep what we + // have rather than blanking a populated tree on a transient failure. + return + } + // The await above suspended the main actor; discard the result if the + // user signed out meanwhile, so a slow load never repopulates after + // sign-out (mirrors the loadPairedMacs user-switch guard). + guard isSignedIn else { + registryDevices = [] + return + } + let connectedID = connectedMacDeviceID + registryDevices = loaded.sorted { lhs, rhs in + let lhsConnected = lhs.deviceId == connectedID + let rhsConnected = rhs.deviceId == connectedID + if lhsConnected != rhsConnected { return lhsConnected } + return lhs.lastSeenAt > rhs.lastSeenAt + } + } + + /// Connect the live session to a specific registry app instance (a tag on a + /// device) using that instance's advertised routes. + /// + /// This is the device tree's tap-to-open for a tag that is not the currently + /// connected one: it routes through the same destructive ``connectManualHost`` + /// path the multi-Mac switcher uses, then persists the device as the active + /// paired Mac on success (so a later relaunch reconnects to it) and refreshes + /// the paired-Mac list. A no-op when the instance advertises no reachable + /// route. Failure surfaces through ``connectionError`` like any other connect. + /// - Parameters: + /// - device: The registry device the instance belongs to. + /// - instance: The tag/app-instance to connect to. + public func connectToRegistryInstance( + device: RegistryDevice, + instance: RegistryAppInstance + ) async { + let supportedKinds = runtime?.supportedRouteKinds ?? [] + guard let (host, port) = Self.firstReconnectHostPortRoute( + instance.routes, + supportedKinds: supportedKinds + ), let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) else { + mobileShellLog.error( + "connectToRegistryInstance: no reconnectable route device=\(device.deviceId, privacy: .public) tag=\(instance.tag, privacy: .public)" + ) + return + } + // Already connected to this exact device/instance route: nothing to do. + if connectionState == .connected, + connectedMacDeviceID == device.deviceId, + case let .hostPort(liveHost, livePort)? = activeRoute?.endpoint, + liveHost == normalizedHost, livePort == port { + return + } + await connectManualHost(name: device.displayName ?? host, host: host, port: port) + // Persist as the active paired Mac only when the live connection is to + // THIS route (a switch tapped while this connect was in flight could win + // the connection; matching the live route avoids persisting a stale + // target). Uses the real device id so reconnect-on-relaunch finds it. + guard connectionState == .connected, + case let .hostPort(liveHost, livePort)? = activeRoute?.endpoint, + liveHost == normalizedHost, livePort == port else { + return + } + if let pairedMacStore, !device.deviceId.hasPrefix("manual-") { + do { + try await pairedMacStore.upsert( + macDeviceID: device.deviceId, + displayName: device.displayName, + routes: instance.routes, + markActive: true, + stackUserID: identityProvider?.currentUserID + ) + hasKnownPairedMac = true + } catch { + mobileShellLog.error( + "connectToRegistryInstance upsert failed device=\(device.deviceId, privacy: .public) error=\(String(describing: error), privacy: .public)" + ) + } + } + await loadPairedMacs() + await loadRegistryDevices() + } + /// Reload ``pairedMacs`` from the store, scoped to the signed-in Stack user. /// /// A missing current Stack user id yields no pairings rather than falling diff --git a/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift new file mode 100644 index 000000000000..89c043cae266 --- /dev/null +++ b/Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift @@ -0,0 +1,124 @@ +import CMUXMobileCore +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +/// Tests the pure `GET /api/devices` → device-tree model decode that backs the +/// hierarchical device tree (device → app instances/tags → routes). This is the +/// data contract the tree renders, so it must keep the registry's two-level +/// shape and the forward-compatible failable-per-route behavior. +@Suite struct DeviceRegistryListParsingTests { + private static let sample = """ + { + "teamId": "team-a", + "devices": [ + { + "deviceId": "AAAA1111-1111-4111-8111-111111111111", + "platform": "mac", + "displayName": "Lawrence's Mac", + "lastSeenAt": "2026-06-08T10:00:00.000Z", + "instances": [ + { + "tag": "stable", + "lastSeenAt": "2026-06-08T10:00:00.000Z", + "routes": [ + { "id": "r1", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.1.1.1", "port": 51001 } } + ] + }, + { + "tag": "dog", + "lastSeenAt": "2026-06-08T09:00:00.000Z", + "routes": [ + { "id": "bad", "kind": "unknown_future_kind", "endpoint": { "type": "???" } }, + { "id": "r2", "kind": "tailscale", "priority": 0, + "endpoint": { "type": "host_port", "host": "100.2.2.2", "port": 51002 } } + ] + } + ] + }, + { + "deviceId": "BBBB2222-2222-4222-8222-222222222222", + "platform": "ios", + "displayName": "Lawrence's iPhone", + "lastSeenAt": "2026-06-08T08:00:00.000Z", + "instances": [] + } + ] + } + """.data(using: .utf8)! + + @Test func parsesTwoLevelDeviceTree() throws { + let devices = try #require(DeviceRegistryService.parseDeviceList(in: Self.sample)) + #expect(devices.count == 2) + + let mac = try #require(devices.first { $0.platform == "mac" }) + #expect(mac.deviceId == "AAAA1111-1111-4111-8111-111111111111") + #expect(mac.displayName == "Lawrence's Mac") + #expect(mac.title == "Lawrence's Mac") + #expect(mac.isControllableHost) + // Two tagged app instances on the same device. + #expect(mac.instances.count == 2) + #expect(Set(mac.instances.map(\.tag)) == ["stable", "dog"]) + } + + @Test func malformedRouteIsDroppedNotFatal() throws { + // The "dog" instance has one unknown-kind route and one good route: the + // good one survives, the bad one is skipped (forward-compat contract). + let devices = try #require(DeviceRegistryService.parseDeviceList(in: Self.sample)) + let mac = try #require(devices.first { $0.platform == "mac" }) + let dog = try #require(mac.instances.first { $0.tag == "dog" }) + #expect(dog.routes.count == 1) + #expect(dog.routes.first?.id == "r2") + #expect(dog.hasRoutes) + } + + @Test func iosDeviceIsParsedButNotControllable() throws { + // The phone-self row is parsed (so the count is honest) but is filtered as + // a non-controllable host by the tree, never offered as a tappable target. + let devices = try #require(DeviceRegistryService.parseDeviceList(in: Self.sample)) + let phone = try #require(devices.first { $0.platform == "ios" }) + #expect(!phone.isControllableHost) + #expect(phone.instances.isEmpty) + } + + @Test func lastSeenIsParsedFromISO8601() throws { + let devices = try #require(DeviceRegistryService.parseDeviceList(in: Self.sample)) + let mac = try #require(devices.first { $0.platform == "mac" }) + // 2026-06-08T10:00:00Z is well after distantPast: a real timestamp parsed. + #expect(mac.lastSeenAt > Date(timeIntervalSince1970: 1_700_000_000)) + let stable = try #require(mac.instances.first { $0.tag == "stable" }) + let dog = try #require(mac.instances.first { $0.tag == "dog" }) + #expect(stable.lastSeenAt > dog.lastSeenAt) + } + + @Test func emptyTagDefaultsToDefault() throws { + let json = """ + { "teamId": "t", "devices": [ + { "deviceId": "CCCC3333-3333-4333-8333-333333333333", "platform": "mac", + "instances": [ { "routes": [] } ] } + ] } + """.data(using: .utf8)! + let devices = try #require(DeviceRegistryService.parseDeviceList(in: json)) + #expect(devices.first?.instances.first?.tag == "default") + #expect(devices.first?.instances.first?.hasRoutes == false) + } + + @Test func malformedEnvelopeReturnsNil() { + #expect(DeviceRegistryService.parseDeviceList(in: Data("not json".utf8)) == nil) + #expect(DeviceRegistryService.parseDeviceList(in: Data("{}".utf8)) == nil) + } + + @Test func deviceWithoutIdIsDropped() throws { + let json = """ + { "teamId": "t", "devices": [ + { "deviceId": " ", "platform": "mac", "instances": [] }, + { "deviceId": "DDDD4444-4444-4444-8444-444444444444", "platform": "mac", "instances": [] } + ] } + """.data(using: .utf8)! + let devices = try #require(DeviceRegistryService.parseDeviceList(in: json)) + #expect(devices.count == 1) + #expect(devices.first?.deviceId == "DDDD4444-4444-4444-8444-444444444444") + } +} diff --git a/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift new file mode 100644 index 000000000000..2d0fc78e9240 --- /dev/null +++ b/Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift @@ -0,0 +1,95 @@ +public import CMUXMobileCore +public import Foundation + +/// One tagged cmux app instance running on a registered device, as returned by +/// the team-scoped device registry (`GET /api/devices`). +/// +/// Mirrors a `device_app_instances` row: a `(deviceId, tag)` pair carrying the +/// attach `routes` the phone uses to reach that specific build. The registry is +/// port-flexible, so the reachable endpoint lives in `routes` rather than a +/// fixed column. +public struct RegistryAppInstance: Equatable, Sendable, Identifiable { + /// The cmux build tag this instance runs (`"stable"`, a dev tag, or + /// `"default"` when the build does not distinguish tags). + public var tag: String + /// Attach routes advertised by this instance, ordered by priority. Decoded + /// failably and individually upstream, so a malformed/unknown-kind route is + /// dropped rather than failing the whole instance. + public var routes: [CmxAttachRoute] + /// When the registry last saw this instance register/refresh. Drives the + /// best-effort "last seen N ago" liveness hint when no live link exists. + public var lastSeenAt: Date + + /// The tag is unique per device, so it doubles as the per-device row id. + public var id: String { tag } + + public init(tag: String, routes: [CmxAttachRoute], lastSeenAt: Date) { + self.tag = tag + self.routes = routes + self.lastSeenAt = lastSeenAt + } + + /// Whether this instance advertises at least one attach route, i.e. it is a + /// candidate the phone could connect to. + public var hasRoutes: Bool { !routes.isEmpty } +} + +/// One registered physical machine (Mac/host) in the team-scoped device +/// registry, with its running cmux app instances. +/// +/// Mirrors a `devices` row plus its `device_app_instances`. This is the +/// two-level model the device tree renders: device → app instances (tags). The +/// `deviceId` here is the cmux-generated device UUID (the wire `deviceId`), not +/// the internal surrogate row id, so it matches `CmxAttachTicket.macDeviceID` +/// for correlating the live connection. +public struct RegistryDevice: Equatable, Sendable, Identifiable { + /// Stable, cross-platform cmux device UUID (matches `MobileHostIdentity` / + /// `CmxAttachTicket.macDeviceID`). Used as the `Identifiable` id and to + /// correlate the active connection. + public var deviceId: String + /// `"mac" | "ios" | "linux" | "windows"`. Only host platforms that advertise + /// routes (typically `"mac"`/`"linux"`) are controllable from the phone. + public var platform: String + /// User-renamable label (e.g. the Mac's name), if the device supplied one. + public var displayName: String? + /// When the registry last saw any registration/refresh for this device. + public var lastSeenAt: Date + /// The device's running cmux app instances (tags), newest-first. + public var instances: [RegistryAppInstance] + + public var id: String { deviceId } + + public init( + deviceId: String, + platform: String, + displayName: String?, + lastSeenAt: Date, + instances: [RegistryAppInstance] + ) { + self.deviceId = deviceId + self.platform = platform + self.displayName = displayName + self.lastSeenAt = lastSeenAt + self.instances = instances + } + + /// A human label for the device: its display name, else the short device id. + public var title: String { + if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return displayName + } + return String(deviceId.prefix(8)) + } + + /// Whether this device is a host the phone can attach to. P1 does not register + /// the phone itself, but guard anyway so an `ios` (or any non-host) row is + /// never rendered as a tappable, connectable host. + public var isControllableHost: Bool { + switch platform.lowercased() { + case "mac", "linux", "windows": + return true + default: + return false + } + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeExpansionStore.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeExpansionStore.swift new file mode 100644 index 000000000000..19352134308a --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeExpansionStore.swift @@ -0,0 +1,44 @@ +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` 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 + + public init(expandedIDs: Set = []) { + 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) + } + } +} diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift new file mode 100644 index 000000000000..8e6b811853e9 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift @@ -0,0 +1,216 @@ +#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). + +/// 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 its last-seen time instead + /// (best-effort liveness; there is no active per-host ping yet). + let liveStatus: MobileMacConnectionStatus? +} + +/// 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 (only non-zero for the connected + /// device's instances, since the registry carries routes, not workspaces). + let workspaceCount: Int + let isConnectedDevice: 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) + } + } + .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, otherwise the relative + /// last-seen time as a best-effort liveness hint. + private var statusLine: String { + if let liveStatus = device.liveStatus { + return liveStatus.label + } + let relative = device.lastSeenAt.formatted(.relative(presentation: .named)) + return String( + format: L10n.string("mobile.deviceTree.lastSeenFormat", defaultValue: "Last seen %@"), + relative + ) + } +} + +/// 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.isConnectedDevice { + 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 isConnectedDevice: 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, !isConnectedDevice { + 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 isConnectedDevice { + 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 diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift new file mode 100644 index 000000000000..8d067afa7d33 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -0,0 +1,239 @@ +#if os(iOS) +import CMUXMobileCore +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. +/// +/// 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. +struct DeviceTreeView: View { + @Bindable var store: CMUXMobileShellStore + /// Open a workspace (the existing tap-to-open path). Forwarded from the shell. + let selectWorkspace: (MobileWorkspacePreview.ID) -> Void + @Environment(\.dismiss) private var dismiss + + /// Persisted expansion shape, encoded as a newline-separated id string. + @AppStorage("cmux.mobile.deviceTree.expanded") private var expandedStorage = "" + @State private var isRefreshing = false + + 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. + private var controllableDevices: [RegistryDevice] { + store.registryDevices.filter(\.isControllableHost) + } + + var body: some View { + NavigationStack { + List { + if controllableDevices.isEmpty { + emptySection + } else { + ForEach(controllableDevices) { device in + deviceSection(device) + } + } + } + .listStyle(.insetGrouped) + .navigationTitle(L10n.string("mobile.deviceTree.title", defaultValue: "Devices")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.string("mobile.common.done", defaultValue: "Done")) { + dismiss() + } + .accessibilityIdentifier("MobileDeviceTreeDone") + } + } + .refreshable { + await store.loadRegistryDevices() + } + .task { + await store.loadRegistryDevices() + } + } + .accessibilityIdentifier("MobileDeviceTree") + } + + @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." + )) + .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; others are described + // by their registry "last seen" (best-effort liveness, no active ping). + // TODO(device-tree): no per-host reachability ping yet for non-connected + // devices, and the attach ticket carries no tag, so we cannot mark which + // tag on a multi-tag device is live — only the connected device overall. + // Surface a real ping + per-tag liveness once the host advertises it. + let liveStatus: MobileMacConnectionStatus? = isConnectedDevice ? store.macConnectionStatus : nil + + Section { + DeviceTreeDeviceRow( + device: DeviceTreeDeviceSnapshot( + deviceId: device.deviceId, + title: device.title, + platform: device.platform, + lastSeenAt: device.lastSeenAt, + instanceCount: device.instances.count, + isConnected: isConnectedDevice, + liveStatus: liveStatus + ), + 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) + // The connected device's live workspaces belong to whatever tag is + // actually running there; since the ticket has no tag, attribute the live + // workspace list to the connected device's instances. With a single + // instance this is exact; the multi-tag case is the TODO above. + let workspaces = isConnectedDevice ? store.workspaces : [] + let captured = DeviceTreeInstanceCapture( + deviceId: device.deviceId, + displayName: device.displayName, + tag: instance.tag, + routes: instance.routes + ) + + DeviceTreeInstanceRow( + instance: DeviceTreeInstanceSnapshot( + tag: instance.tag, + lastSeenAt: instance.lastSeenAt, + hasRoutes: instance.hasRoutes, + workspaceCount: workspaces.count, + isConnectedDevice: isConnectedDevice + ), + isExpanded: expansion.isExpanded(expansionID), + setExpanded: { expanded in setExpanded(expansionID, expanded) }, + connect: connectClosure(for: captured) + ) + + if expansion.isExpanded(expansionID) { + if workspaces.isEmpty { + DeviceTreeWorkspacePlaceholderRow( + isConnectedDevice: isConnectedDevice, + hasRoutes: instance.hasRoutes, + connect: connectClosure(for: captured) + ) + } else { + ForEach(workspaces) { workspace in + WorkspaceNavigationRow( + workspace: workspace, + connectionStatus: store.macConnectionStatus, + isSelected: false, + navigationStyle: .push, + wrapWorkspaceTitles: false, + 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 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 diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 5e5587c4c436..526bf973a189 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -1,6 +1,7 @@ #if os(iOS) import CmuxAuthRuntime import CmuxMobileShell +import CmuxMobileShellModel import CmuxMobileSupport import SwiftUI @@ -18,9 +19,14 @@ struct MobileSettingsView: View { /// The shell store, used to drive the multi-Mac switcher. `nil` in previews, /// where the "Switch Mac" entry is hidden. var store: CMUXMobileShellStore? + /// Open a workspace from the device tree. Forwarded from the workspace list so + /// tapping a workspace leaf in the tree drives the existing open path. `nil` + /// in previews / contexts without a tree, where the "Devices" entry is hidden. + var selectWorkspace: ((MobileWorkspacePreview.ID) -> Void)? @Environment(\.dismiss) private var dismiss @State private var showingShortcuts = false + @State private var showingDeviceTree = false /// Mirrors ``MobilePushCoordinator/isEnabled`` so the toggle's label/icon /// update after the async enable/disable. The coordinator exposes /// `isEnabled` as a non-observable `UserDefaults` read, so reading it @@ -70,6 +76,17 @@ struct MobileSettingsView: View { value: connectedHostName ) } + if store != nil, selectWorkspace != nil { + Button { + showingDeviceTree = true + } label: { + Label( + L10n.string("mobile.settings.devices", defaultValue: "Devices"), + systemImage: "rectangle.stack" + ) + } + .accessibilityIdentifier("MobileSettingsDevices") + } if store != nil { Button { showingHostPicker = true @@ -168,6 +185,11 @@ struct MobileSettingsView: View { MobileHostPickerView(store: store) } } + .sheet(isPresented: $showingDeviceTree) { + if let store, let selectWorkspace { + DeviceTreeView(store: store, selectWorkspace: selectWorkspace) + } + } } .accessibilityIdentifier("MobileSettingsView") } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index ca04a2d3eb08..1cc673d11eb3 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -116,7 +116,8 @@ struct WorkspaceListView: View { connectedHostName: host, rescanQR: rescanQR, signOut: signOut, - store: store + store: store, + selectWorkspace: selectWorkspace ) } #endif diff --git a/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/DeviceTreeExpansionStoreTests.swift b/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/DeviceTreeExpansionStoreTests.swift new file mode 100644 index 000000000000..95e2144ff3ad --- /dev/null +++ b/Packages/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/DeviceTreeExpansionStoreTests.swift @@ -0,0 +1,30 @@ +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) + } +} diff --git a/Packages/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift b/Packages/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift index 6a4faca8f6ca..5d5866fd23cf 100644 --- a/Packages/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift +++ b/Packages/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift @@ -40,6 +40,21 @@ public struct L10n { return String(format: string("mobile.workspace.terminalCountFormat.other", defaultValue: "%d terminals"), count) } + /// A localized "N workspaces" count label with singular/plural handling, for + /// the device tree's per-build workspace summary. + /// + /// - Parameter count: The number of workspaces. + /// - Returns: The localized count phrase. + public static func terminalCountWorkspaces(_ count: Int) -> String { + if count == 1 { + return string("mobile.deviceTree.workspaceCountFormat.one", defaultValue: "1 workspace") + } + return String( + format: string("mobile.deviceTree.workspaceCountFormat.other", defaultValue: "%d workspaces"), + count + ) + } + /// A localized default workspace name for a given 1-based index. /// /// - Parameter index: The 1-based workspace index. diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 82dc3e9e903f..8334fb912abe 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -800,6 +800,227 @@ } } }, + "mobile.deviceTree.collapseHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Collapse builds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ビルドを折りたたむ" + } + } + } + }, + "mobile.deviceTree.connect": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続" + } + } + } + }, + "mobile.deviceTree.connectToSeeWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to this build to see its workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このビルドに接続するとワークスペースが表示されます" + } + } + } + }, + "mobile.deviceTree.connectToView": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to view" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続して表示" + } + } + } + }, + "mobile.deviceTree.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No registered devices yet. Pair a Mac to see it here." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "登録済みのデバイスはまだありません。Macをペアリングするとここに表示されます。" + } + } + } + }, + "mobile.deviceTree.expandHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Expand builds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ビルドを展開" + } + } + } + }, + "mobile.deviceTree.footer": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Devices and their cmux builds come from your team's registry. Tap a build to connect, then a workspace to open it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスとそのcmuxビルドはチームのレジストリから取得されます。ビルドをタップして接続し、ワークスペースをタップして開きます。" + } + } + } + }, + "mobile.deviceTree.lastSeenFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last seen %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最終確認 %@" + } + } + } + }, + "mobile.deviceTree.noRoutes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Not reachable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "到達できません" + } + } + } + }, + "mobile.deviceTree.noWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspaces yet" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースはまだありません" + } + } + } + }, + "mobile.deviceTree.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Devices" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイス" + } + } + } + }, + "mobile.deviceTree.workspaceCountFormat.one": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1個のワークスペース" + } + } + } + }, + "mobile.deviceTree.workspaceCountFormat.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d個のワークスペース" + } + } + } + }, "mobile.devices.emptyDescription": { "extractionState": "manual", "localizations": { @@ -953,6 +1174,40 @@ } } }, + "mobile.notifications.disable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn Off Agent Notifications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント通知をオフにする" + } + } + } + }, + "mobile.notifications.enable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notify Me About Agents" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント通知を受け取る" + } + } + } + }, "mobile.pairing.attachTicketExpired": { "extractionState": "manual", "localizations": { @@ -1446,6 +1701,23 @@ } } }, + "mobile.settings.devices": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Devices" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイス" + } + } + } + }, "mobile.settings.display": { "extractionState": "manual", "localizations": { @@ -3282,6 +3554,23 @@ } } }, + "terminal.input_accessory.paste": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペースト" + } + } + } + }, "terminal.input_accessory.showKeyboard": { "extractionState": "manual", "localizations": { @@ -3452,70 +3741,70 @@ } } }, - "terminal.shortcut.name.ctrlL": { + "terminal.shortcut.name.alternate": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear (Control-L)" + "value": "Option" } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリア (Control-L)" + "value": "Option" } } } }, - "terminal.shortcut.name.control": { + "terminal.shortcut.name.command": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "Command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "Command" } } } }, - "terminal.shortcut.name.alternate": { + "terminal.shortcut.name.control": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Option" + "value": "Control" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Option" + "value": "Control" } } } }, - "terminal.shortcut.name.command": { + "terminal.shortcut.name.ctrlL": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "Clear (Control-L)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "クリア (Control-L)" } } } @@ -3587,57 +3876,6 @@ } } } - }, - "mobile.notifications.enable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Notify Me About Agents" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェント通知を受け取る" - } - } - } - }, - "mobile.notifications.disable": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Turn Off Agent Notifications" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェント通知をオフにする" - } - } - } - }, - "terminal.input_accessory.paste": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Paste" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ペースト" - } - } - } } }, "version": "1.0" From c0b9d04aba25888c63d19a5a0cfd666a62cf0322 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:18:47 -0700 Subject: [PATCH 02/11] Device tree: fix account-switch race + multi-tag wrong-tag workspaces (autoreview P1s) P1-1: loadRegistryDevices now captures the requesting user id and discards a result that lands after a sign-out + different-user sign-in, so a slow registry load can't leak a previous user's team devices into the new user's tree (mirrors loadPairedMacs's user guard). P1-2: attribute live workspaces to the ONE instance whose route matches the live connection (instanceMatchesActiveRoute), not every tag on the connected device. A multi-tag Mac now shows workspaces only under the connected build; the other tags offer Connect instead of mirroring the wrong build's workspaces, so a workspace can no longer be opened under the wrong tag. Co-Authored-By: Claude Opus 4.8 --- .../MobileShellComposite.swift | 13 +++-- .../CmuxMobileShellUI/DeviceTreeRows.swift | 17 +++--- .../CmuxMobileShellUI/DeviceTreeView.swift | 52 ++++++++++++++----- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d6a1c21657a5..e99afe6a7f8e 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1108,15 +1108,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { registryDevices = [] return } + // Capture the requesting user so a result that lands after a sign-out + + // different-user sign-in is discarded, not assigned into the new user's + // tree. `isSignedIn` alone is true again after the switch, so it cannot + // catch this account-switch race (mirrors loadPairedMacs's user guard). + let requestingUserID = identityProvider?.currentUserID guard let loaded = await deviceRegistry.listDevices() else { // nil == registry unavailable/unauthorized/malformed: keep what we // have rather than blanking a populated tree on a transient failure. return } - // The await above suspended the main actor; discard the result if the - // user signed out meanwhile, so a slow load never repopulates after - // sign-out (mirrors the loadPairedMacs user-switch guard). - guard isSignedIn else { + // The await above suspended the main actor; discard the result unless we + // are still the same signed-in user, so a slow load can never repopulate + // another user's team devices after sign-out or an account switch. + guard isSignedIn, identityProvider?.currentUserID == requestingUserID else { registryDevices = [] return } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift index 8e6b811853e9..00e01f1787fe 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift @@ -29,10 +29,13 @@ struct DeviceTreeInstanceSnapshot: Equatable { let lastSeenAt: Date /// Whether this instance advertises at least one reachable route. let hasRoutes: Bool - /// Workspaces visible under this instance (only non-zero for the connected - /// device's instances, since the registry carries routes, not workspaces). + /// Workspaces visible under this instance (non-zero only for the active + /// instance, since the registry carries routes, not workspaces). let workspaceCount: Int - let isConnectedDevice: Bool + /// 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, @@ -157,7 +160,7 @@ struct DeviceTreeInstanceRow: View { } private var subtitle: String { - if instance.isConnectedDevice { + if instance.isActiveInstance { return L10n.terminalCountWorkspaces(instance.workspaceCount) } if !instance.hasRoutes { @@ -175,7 +178,7 @@ struct DeviceTreeInstanceRow: View { /// either it is not the connected build (offer Connect) or it is connected but /// has no workspaces yet. struct DeviceTreeWorkspacePlaceholderRow: View { - let isConnectedDevice: Bool + let isActiveInstance: Bool let hasRoutes: Bool let connect: (() -> Void)? @@ -185,7 +188,7 @@ struct DeviceTreeWorkspacePlaceholderRow: View { .font(.caption) .foregroundStyle(.secondary) Spacer(minLength: 8) - if let connect, !isConnectedDevice { + if let connect, !isActiveInstance { Button { connect() } label: { @@ -201,7 +204,7 @@ struct DeviceTreeWorkspacePlaceholderRow: View { } private var message: String { - if isConnectedDevice { + if isActiveInstance { return L10n.string("mobile.deviceTree.noWorkspaces", defaultValue: "No workspaces yet") } if !hasRoutes { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index 8d067afa7d33..7e4f8e95b841 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -96,10 +96,11 @@ struct DeviceTreeView: View { let isConnectedDevice = device.deviceId == connectedID // Live status only exists for the connected device; others are described // by their registry "last seen" (best-effort liveness, no active ping). - // TODO(device-tree): no per-host reachability ping yet for non-connected - // devices, and the attach ticket carries no tag, so we cannot mark which - // tag on a multi-tag device is live — only the connected device overall. - // Surface a real ping + per-tag liveness once the host advertises it. + // The live *tag* on a multi-tag device is identified by route match (see + // instanceMatchesActiveRoute), so per-instance liveness is correct. + // TODO(device-tree): there is still no active per-host reachability ping + // for non-connected devices, so their dot is last-seen-only. Surface a + // real ping once the host advertises one. let liveStatus: MobileMacConnectionStatus? = isConnectedDevice ? store.macConnectionStatus : nil Section { @@ -132,17 +133,24 @@ struct DeviceTreeView: View { isConnectedDevice: Bool ) -> some View { let expansionID = instanceExpansionID(device: device, instance: instance) - // The connected device's live workspaces belong to whatever tag is - // actually running there; since the ticket has no tag, attribute the live - // workspace list to the connected device's instances. With a single - // instance this is exact; the multi-tag case is the TODO above. - let workspaces = isConnectedDevice ? store.workspaces : [] + // 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 workspaces = isActiveInstance ? store.workspaces : [] let captured = DeviceTreeInstanceCapture( deviceId: device.deviceId, displayName: device.displayName, tag: instance.tag, routes: instance.routes ) + // 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( @@ -150,19 +158,19 @@ struct DeviceTreeView: View { lastSeenAt: instance.lastSeenAt, hasRoutes: instance.hasRoutes, workspaceCount: workspaces.count, - isConnectedDevice: isConnectedDevice + isActiveInstance: isActiveInstance ), isExpanded: expansion.isExpanded(expansionID), setExpanded: { expanded in setExpanded(expansionID, expanded) }, - connect: connectClosure(for: captured) + connect: connect ) if expansion.isExpanded(expansionID) { if workspaces.isEmpty { DeviceTreeWorkspacePlaceholderRow( - isConnectedDevice: isConnectedDevice, + isActiveInstance: isActiveInstance, hasRoutes: instance.hasRoutes, - connect: connectClosure(for: captured) + connect: connect ) } else { ForEach(workspaces) { workspace in @@ -211,6 +219,24 @@ struct DeviceTreeView: View { } } + /// 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)" } From 4b36c2693b254e79b7a809051b91f2d2e34dfd47 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:22:52 -0700 Subject: [PATCH 03/11] Device tree: roll back failed registry connect + paired-Mac fallback (autoreview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: connectToRegistryInstance now captures the previously-active Mac and, when the destructive connect fails to land on the target route, reconnects it (mirrors switchToMac). Tapping a stale/offline registry tag no longer drops a healthy live session; the user is left where they were. P2: add store.deviceTreeDevices, which honors the documented best-effort fallback: the registry list when loaded, otherwise the locally paired Macs synthesized into the same device→instance shape. The tree now sources from it and loads paired Macs first, so the Devices sheet stays usable (and connectable) during a registry outage instead of showing the empty state. Co-Authored-By: Claude Opus 4.8 --- .../MobileShellComposite.swift | 52 +++++++++++++++++++ .../CmuxMobileShellUI/DeviceTreeView.swift | 9 +++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index e99afe6a7f8e..fc229f80b687 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1134,6 +1134,43 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } } + /// The device-tree data source, honoring the registry's best-effort/fallback + /// contract: the registry list when it loaded, otherwise the locally paired + /// Macs synthesized into the same two-level shape. + /// + /// When `/api/devices` is unreachable, unauthorized, or malformed, + /// ``registryDevices`` stays empty; the tree must not collapse to "no devices" + /// while the phone still has usable paired Macs. Each paired Mac becomes a + /// device with a single `default` instance carrying its routes, so the tree + /// (and its connect-on-tap) keeps working with the cloud down. The connected + /// device sorts first, then most-recently-seen. + public var deviceTreeDevices: [RegistryDevice] { + if !registryDevices.isEmpty { return registryDevices } + let connectedID = connectedMacDeviceID + return pairedMacs + .map { mac in + RegistryDevice( + deviceId: mac.macDeviceID, + platform: "mac", + displayName: mac.displayName, + lastSeenAt: mac.lastSeenAt, + instances: [ + RegistryAppInstance( + tag: "default", + routes: mac.routes, + lastSeenAt: mac.lastSeenAt + ) + ] + ) + } + .sorted { lhs, rhs in + let lhsConnected = lhs.deviceId == connectedID + let rhsConnected = rhs.deviceId == connectedID + if lhsConnected != rhsConnected { return lhsConnected } + return lhs.lastSeenAt > rhs.lastSeenAt + } + } + /// Connect the live session to a specific registry app instance (a tag on a /// device) using that instance's advertised routes. /// @@ -1143,6 +1180,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// paired Mac on success (so a later relaunch reconnects to it) and refreshes /// the paired-Mac list. A no-op when the instance advertises no reachable /// route. Failure surfaces through ``connectionError`` like any other connect. + /// + /// Like ``switchToMac(macDeviceID:)``, the connect is destructive (it replaces + /// the live client), so tapping a stale/offline tag while connected would drop + /// a healthy session. To avoid stranding the user, on a failed connect the + /// previously-active Mac is reconnected, so a bad target leaves the user where + /// they were rather than disconnected. /// - Parameters: /// - device: The registry device the instance belongs to. /// - instance: The tag/app-instance to connect to. @@ -1167,6 +1210,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { liveHost == normalizedHost, livePort == port { return } + // The currently-active Mac to fall back to if the connect fails, so the + // destructive connect below can be rolled back (mirrors switchToMac). + let previousActive = pairedMacs.first { $0.isActive && $0.macDeviceID != device.deviceId } await connectManualHost(name: device.displayName ?? host, host: host, port: port) // Persist as the active paired Mac only when the live connection is to // THIS route (a switch tapped while this connect was in flight could win @@ -1175,6 +1221,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { guard connectionState == .connected, case let .hostPort(liveHost, livePort)? = activeRoute?.endpoint, liveHost == normalizedHost, livePort == port else { + // The connect did not land on this route. If the destructive path + // dropped a previously-active session, reconnect it so a failed tap on + // a stale/offline tag does not strand the user disconnected. + if previousActive != nil, connectionState != .connected { + _ = await reconnectActiveMacIfAvailable(stackUserID: identityProvider?.currentUserID) + } return } if let pairedMacStore, !device.deviceId.hasPrefix("manual-") { diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift index 7e4f8e95b841..7bf099af6493 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -37,9 +37,10 @@ struct DeviceTreeView: View { /// 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. + /// 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.registryDevices.filter(\.isControllableHost) + store.deviceTreeDevices.filter(\.isControllableHost) } var body: some View { @@ -65,9 +66,13 @@ struct DeviceTreeView: View { } } .refreshable { + await store.loadPairedMacs() await store.loadRegistryDevices() } .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() } } From 34d4f9e1d984f37032cf4c51e9b234171816fb9f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:29:05 -0700 Subject: [PATCH 04/11] Device tree: roll back to the active Mac even on a same-device tag switch (autoreview P1) The previous rollback excluded the tapped device id (copied from switchToMac), which is wrong here: a Mac runs multiple tagged builds, so tapping another tag on the currently-connected device must still be able to reconnect that device's active route when the new tag is stale/offline. Capture the active paired Mac regardless of device id so a same-device tag-switch failure restores the live session instead of stranding the user disconnected. Co-Authored-By: Claude Opus 4.8 --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index fc229f80b687..16c1e9c5fbbc 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1211,8 +1211,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } // The currently-active Mac to fall back to if the connect fails, so the - // destructive connect below can be rolled back (mirrors switchToMac). - let previousActive = pairedMacs.first { $0.isActive && $0.macDeviceID != device.deviceId } + // destructive connect below can be rolled back. Unlike switchToMac, this + // does NOT exclude the tapped device: a Mac can run multiple tagged builds, + // so tapping another tag on the *currently connected* device must still be + // able to reconnect that same device's active route if the new tag is + // stale/offline. Excluding it would strand the user on a same-device tag + // switch failure. + let previousActive = pairedMacs.first { $0.isActive } await connectManualHost(name: device.displayName ?? host, host: host, port: port) // Persist as the active paired Mac only when the live connection is to // THIS route (a switch tapped while this connect was in flight could win From ab9bf3ddc4f54efcb266ebe137d7370e45902d0b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:37:44 -0700 Subject: [PATCH 05/11] Device tree: clear team-scoped registry data on auth rejection (autoreview P1) listDevices() now returns a 3-way outcome (ok / authRejected / transientFailure) instead of an optional, so the store can distinguish a transient blip (keep the tree) from a 401/403 auth/scope rejection (clear it). The registry is team-scoped, so a token/scope change must not leave a previous scope's team-device names/tags/ routes visible; on authRejected the store clears registryDevices and the tree falls back to local paired Macs. Transient failures (5xx, network, malformed body) keep the current tree to avoid blip-blanking. Co-Authored-By: Claude Opus 4.8 --- .../DeviceRegistryRefreshing.swift | 27 +++++++++++++---- .../DeviceRegistryService.swift | 29 +++++++++++++++---- .../MobileShellComposite.swift | 18 ++++++++++-- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift index 918c332830c0..a4bfcdcee262 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift @@ -28,9 +28,26 @@ public protocol DeviceRegistryRefreshing: Sendable { /// /// The same team-scoped `GET /api/devices` response that backs /// ``freshRoutes(forMacDeviceID:)``, decoded into the full two-level model - /// rather than narrowed to one Mac's routes. Best-effort like the rest of the - /// registry: returns `nil` when the registry is unreachable, the call is - /// unauthorized, or the response is malformed, so the tree falls back to the - /// locally known paired Macs and the app keeps working with the cloud down. - func listDevices() async -> [RegistryDevice]? + /// rather than narrowed to one Mac's routes. Returns a three-way outcome so + /// the caller can tell a transient failure (keep the current tree) from an + /// auth/scope rejection (clear it). The registry is team-scoped, so a 401/403 + /// after the token/scope changed must NOT keep the previous scope's + /// team-device data visible. + func listDevices() async -> DeviceRegistryListOutcome +} + +/// The outcome of a device-list registry read, distinguishing the cases that +/// must clear the cached team-device data from those that must keep it. +public enum DeviceRegistryListOutcome: Sendable { + /// A successful read; the decoded device list (possibly empty). + case ok([RegistryDevice]) + /// The registry rejected the call on authorization/scope grounds (a non-2xx + /// 401/403). The cached, possibly other-scope, device data must be cleared so + /// it cannot leak into the new auth context; the UI falls back to local + /// paired Macs. + case authRejected + /// A transient failure (network error, timeout, malformed body, or any other + /// non-auth non-2xx). The current device list should be kept so a blip never + /// blanks a populated tree. + case transientFailure } diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index a01759ad34a9..949d70da7afa 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -155,22 +155,39 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return Self.routes(forMacDeviceID: macDeviceID, in: data) } - public func listDevices() async -> [RegistryDevice]? { + public func listDevices() async -> DeviceRegistryListOutcome { + // No request could be built (no valid session/tokens): treat as a + // transient failure rather than an auth rejection, since this is the + // signed-out / not-yet-bootstrapped case, not the registry actively + // rejecting the caller's scope. guard let request = await makeRequest(method: "GET", path: "/api/devices", body: nil) else { - return nil + return .transientFailure } let data: Data do { let (responseData, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { - return nil + guard let http = response as? HTTPURLResponse else { + return .transientFailure + } + // An auth/scope rejection (401/403) must clear the cached team-scoped + // data; any other non-2xx (5xx, etc.) is transient and keeps it. + if http.statusCode == 401 || http.statusCode == 403 { + return .authRejected + } + guard (200...299).contains(http.statusCode) else { + return .transientFailure } data = responseData } catch { deviceRegistryLog.debug("listDevices request failed: \(String(describing: error), privacy: .public)") - return nil + return .transientFailure + } + // A 2xx with an undecodable body is a server/contract glitch, not an auth + // rejection: keep the current tree rather than blanking it. + guard let devices = Self.parseDeviceList(in: data) else { + return .transientFailure } - return Self.parseDeviceList(in: data) + return .ok(devices) } // MARK: - Parsing (pure, testable) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 16c1e9c5fbbc..d564bfbf58e9 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1113,9 +1113,21 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // tree. `isSignedIn` alone is true again after the switch, so it cannot // catch this account-switch race (mirrors loadPairedMacs's user guard). let requestingUserID = identityProvider?.currentUserID - guard let loaded = await deviceRegistry.listDevices() else { - // nil == registry unavailable/unauthorized/malformed: keep what we - // have rather than blanking a populated tree on a transient failure. + let outcome = await deviceRegistry.listDevices() + let loaded: [RegistryDevice] + switch outcome { + case .ok(let devices): + loaded = devices + case .authRejected: + // The registry is team-scoped and rejected the call on auth/scope + // grounds (401/403): the cached list may be another scope's data, so + // clear it. The tree falls back to local paired Macs via + // `deviceTreeDevices`, so the sheet stays usable. + registryDevices = [] + return + case .transientFailure: + // Network blip / 5xx / malformed body: keep what we have rather than + // blanking a populated tree on a transient failure. return } // The await above suspended the main actor; discard the result unless we From a7c80399061683ada8b5d622481cb3205a019b68 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:44:48 -0700 Subject: [PATCH 06/11] Device tree: present from workspace list (single sheet) + recognize manual-ticket active device (autoreview P2) P2 (sheet stack): move the device tree to a top-level sheet on the workspace list (a Devices toolbar button) instead of nesting it under the Settings sheet, so selecting a workspace dismisses straight back to the workspace shell and reveals the opened workspace rather than leaving Settings covering it. Removes the duplicate Settings 'Devices' entry. P2 (manual-ticket active): connectedMacDeviceID now falls back to the active paired Mac's real device id when the live ticket is a synthetic manual one (host without mobile.attach_ticket.create). The registry connect path persists the real device as active, so the tree now marks it connected and shows its live workspaces instead of hiding them. Co-Authored-By: Claude Opus 4.8 --- .../MobileShellComposite.swift | 32 +++++++++++++------ .../MobileSettingsView.swift | 22 ------------- .../CmuxMobileShellUI/WorkspaceListView.swift | 30 +++++++++++++++-- 3 files changed, 50 insertions(+), 34 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index d564bfbf58e9..297be8d0149c 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1081,18 +1081,30 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// The cmux device id of the Mac the live connection currently targets, or /// `nil` when not connected. Used by the device tree to mark which device row - /// is live. Derived from the active attach ticket's `macDeviceID`; a manual - /// (`manual-…`) ticket has no real device id, so it does not correlate to a - /// registry row and yields `nil` (the tree then shows no device as connected, - /// which is honest for a manual host that is not in the registry). + /// is live. + /// + /// Prefers the active attach ticket's real `macDeviceID`. A manual (`manual-…`) + /// ticket has no real device id (the host lacks `mobile.attach_ticket.create`, + /// so the connect synthesizes a manual ticket even on success); in that case, + /// fall back to the active paired Mac's device id, which the registry/switch + /// connect paths persist on success. This keeps the connected device — and its + /// live workspaces — visible in the tree even when the live ticket is manual. + /// Yields `nil` only when there is genuinely no real device id to correlate. public var connectedMacDeviceID: String? { - guard connectionState == .connected, - let macDeviceID = activeTicket?.macDeviceID, - !macDeviceID.isEmpty, - !macDeviceID.hasPrefix("manual-") else { - return nil + guard connectionState == .connected else { return nil } + if let macDeviceID = activeTicket?.macDeviceID, + !macDeviceID.isEmpty, + !macDeviceID.hasPrefix("manual-") { + return macDeviceID + } + // Manual/synthetic ticket but a live connection: correlate via the active + // paired Mac the connect path persisted (its id is the real device id). + if let activeMacID = pairedMacs.first(where: { $0.isActive })?.macDeviceID, + !activeMacID.isEmpty, + !activeMacID.hasPrefix("manual-") { + return activeMacID } - return macDeviceID + return nil } /// Reload ``registryDevices`` from the team-scoped device registry. diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 526bf973a189..5e5587c4c436 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -1,7 +1,6 @@ #if os(iOS) import CmuxAuthRuntime import CmuxMobileShell -import CmuxMobileShellModel import CmuxMobileSupport import SwiftUI @@ -19,14 +18,9 @@ struct MobileSettingsView: View { /// The shell store, used to drive the multi-Mac switcher. `nil` in previews, /// where the "Switch Mac" entry is hidden. var store: CMUXMobileShellStore? - /// Open a workspace from the device tree. Forwarded from the workspace list so - /// tapping a workspace leaf in the tree drives the existing open path. `nil` - /// in previews / contexts without a tree, where the "Devices" entry is hidden. - var selectWorkspace: ((MobileWorkspacePreview.ID) -> Void)? @Environment(\.dismiss) private var dismiss @State private var showingShortcuts = false - @State private var showingDeviceTree = false /// Mirrors ``MobilePushCoordinator/isEnabled`` so the toggle's label/icon /// update after the async enable/disable. The coordinator exposes /// `isEnabled` as a non-observable `UserDefaults` read, so reading it @@ -76,17 +70,6 @@ struct MobileSettingsView: View { value: connectedHostName ) } - if store != nil, selectWorkspace != nil { - Button { - showingDeviceTree = true - } label: { - Label( - L10n.string("mobile.settings.devices", defaultValue: "Devices"), - systemImage: "rectangle.stack" - ) - } - .accessibilityIdentifier("MobileSettingsDevices") - } if store != nil { Button { showingHostPicker = true @@ -185,11 +168,6 @@ struct MobileSettingsView: View { MobileHostPickerView(store: store) } } - .sheet(isPresented: $showingDeviceTree) { - if let store, let selectWorkspace { - DeviceTreeView(store: store, selectWorkspace: selectWorkspace) - } - } } .accessibilityIdentifier("MobileSettingsView") } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index 1cc673d11eb3..38cf03779b20 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -37,6 +37,7 @@ struct WorkspaceListView: View { @State private var searchText = "" @State private var showingShortcutsSettings = false @State private var showingSettings = false + @State private var showingDeviceTree = false /// Workspaces after search filtering, pinned ones first (stable within each /// group so the Mac's order is otherwise preserved). @@ -97,6 +98,11 @@ struct WorkspaceListView: View { ToolbarItem(placement: .topBarLeading) { settingsMenu } + if store != nil { + ToolbarItem(placement: .topBarLeading) { + devicesButton + } + } ToolbarItem(placement: .topBarTrailing) { newWorkspaceButton } @@ -116,13 +122,33 @@ struct WorkspaceListView: View { connectedHostName: host, rescanQR: rescanQR, signOut: signOut, - store: store, - selectWorkspace: selectWorkspace + store: store ) } + // Present the device tree at the workspace-list level (a single sheet, + // not nested under Settings), so selecting a workspace dismisses straight + // back to the workspace shell and reveals the opened workspace rather than + // leaving a parent sheet covering it. + .sheet(isPresented: $showingDeviceTree) { + if let store { + DeviceTreeView(store: store, selectWorkspace: selectWorkspace) + } + } #endif } + #if os(iOS) + private var devicesButton: some View { + Button { + showingDeviceTree = true + } label: { + Image(systemName: "rectangle.stack") + } + .accessibilityLabel(L10n.string("mobile.settings.devices", defaultValue: "Devices")) + .accessibilityIdentifier("MobileWorkspaceDevicesButton") + } + #endif + private var newWorkspaceButton: some View { Button(action: createWorkspace) { Image(systemName: "plus") From dd4ac6406ae21daa2a5af83bf443c30321e54445 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:57:12 -0700 Subject: [PATCH 07/11] iOS: mobile browser panes P1 (WKWebView surface) Add a phone-local WKWebView browser pane as a sibling of the terminal surface in the iOS companion app. New `CmuxMobileBrowser` package owns the browser surface state, URL resolver, store, and the WKWebView host; `CmuxMobileShellUI` presents it from the same workspace toolbar menu that creates terminals ("New Browser"), and a close action returns to the terminal. Browser state lives in a dedicated workspace-keyed `BrowserSurfaceStore` injected from the app root, not in `MobileShellComposite`, because a browser has no Mac-side counterpart and must survive workspace.updated re-syncs (unlike terminals). P1 ships: address bar, navigate, back/forward/reload/stop, page title, determinate loading progress, default persistent WKWebsiteDataStore. P2 (cookie/localStorage sync with the Mac) and P3 (passkeys) are deferred; see plans/feat-ios-mobile-browser/DESIGN.md. Co-Authored-By: Claude Opus 4.8 --- Packages/CmuxMobileBrowser/Package.swift | 47 ++++ .../BrowserSurfaceState.swift | 193 +++++++++++++++ .../BrowserSurfaceStore.swift | 92 +++++++ .../BrowserURLResolver.swift | 169 +++++++++++++ .../CmuxMobileBrowser/MobileBrowserPane.swift | 136 +++++++++++ .../CmuxMobileBrowser/MobileBrowserView.swift | 228 ++++++++++++++++++ .../BrowserSurfaceStateTests.swift | 107 ++++++++ .../BrowserSurfaceStoreTests.swift | 72 ++++++ .../BrowserURLResolverTests.swift | 139 +++++++++++ Packages/CmuxMobileShellUI/Package.swift | 2 + .../CmuxMobileShellUI/CMUXMobileAppView.swift | 14 +- .../WorkspaceDetailView.swift | 69 +++++- ios/Config/Info.plist | 11 + ios/cmux/Resources/Localizable.xcstrings | 119 +++++++++ ios/cmuxPackage/Package.swift | 3 + 15 files changed, 1399 insertions(+), 2 deletions(-) create mode 100644 Packages/CmuxMobileBrowser/Package.swift create mode 100644 Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift create mode 100644 Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift create mode 100644 Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift create mode 100644 Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift create mode 100644 Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift create mode 100644 Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift create mode 100644 Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift create mode 100644 Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift diff --git a/Packages/CmuxMobileBrowser/Package.swift b/Packages/CmuxMobileBrowser/Package.swift new file mode 100644 index 000000000000..150d165afce4 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxMobileBrowser", + platforms: [ + .iOS(.v18), + .macOS(.v14), + ], + products: [ + .library( + name: "CmuxMobileBrowser", + targets: ["CmuxMobileBrowser"] + ), + ], + dependencies: [ + // Localized-string helpers (`L10n`). `CmuxMobileSupport` is a leaf with + // no dependencies, so the browser package stays low in the DAG. + .package(path: "../CmuxMobileSupport"), + ], + targets: [ + // A self-contained, phone-local browser surface. P1 browser state never + // touches the Mac, so this package sits low in the DAG: it depends only + // on the leaf `CmuxMobileSupport` and links Foundation/WebKit/SwiftUI. + .target( + name: "CmuxMobileBrowser", + dependencies: [ + "CmuxMobileSupport", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxMobileBrowserTests", + dependencies: ["CmuxMobileBrowser"], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift new file mode 100644 index 000000000000..f30f295c115e --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceState.swift @@ -0,0 +1,193 @@ +public import Foundation +import Observation + +/// The observable state of a single phone-local browser pane. +/// +/// This is the mobile analogue of a terminal surface, but its lifecycle is +/// entirely local: there is no Mac-side counterpart in P1. The view layer +/// (`MobileBrowserView`) drives this from `WKWebView` callbacks; the address +/// bar reads `addressText`, the chrome reads `canGoBack`/`canGoForward`/ +/// `isLoading`/`estimatedProgress`, and a pending ``loadRequest`` tells the +/// representable what URL to load next. +/// +/// It is `@MainActor @Observable` (not `ObservableObject`/`@Published`), so +/// SwiftUI tracks individual property reads and the `WKWebView` coordinator can +/// mutate it directly on the main actor. +@MainActor +@Observable +public final class BrowserSurfaceState: Identifiable { + /// A stable identifier for a browser surface, so SwiftUI can key the hosting + /// representable and tear down the `WKWebView` when the surface changes. + public struct ID: RawRepresentable, Hashable, Sendable { + /// The backing identifier string. + public var rawValue: String + + /// Creates an identifier from its raw string value. + /// - Parameter rawValue: The backing identifier. + public init(rawValue: String) { + self.rawValue = rawValue + } + } + + /// A history/navigation command the chrome can request against the hosted + /// web view. + public enum NavigationCommand: Equatable, Sendable { + /// Navigate back one history entry. + case goBack + /// Navigate forward one history entry. + case goForward + /// Reload the current page. + case reload + /// Stop the in-flight navigation. + case stopLoading + } + + /// The surface's stable identifier. + public let id: ID + + /// The text currently shown in (or being edited in) the address bar. The + /// view keeps this in sync with the live URL when not editing. + public var addressText: String + + /// Whether the user is currently editing the address bar. While `true`, the + /// web view's URL/navigation callbacks must not overwrite ``addressText``, + /// otherwise a redirect or in-flight URL change clobbers the user's typing. + public var isAddressEditing: Bool + + /// The page's reported title, or `nil` before the first navigation + /// resolves a title. + public var title: String? + + /// The page's current committed URL, or `nil` before the first navigation. + public var currentURL: URL? + + /// Whether a navigation is in flight. Drives the progress indicator and the + /// reload/stop button affordance. + public var isLoading: Bool + + /// The latest navigation progress in `0...1`. Only meaningful while + /// ``isLoading`` is `true`. + public var estimatedProgress: Double + + /// Whether the web view can navigate back in its history. + public var canGoBack: Bool + + /// Whether the web view can navigate forward in its history. + public var canGoForward: Bool + + /// A user-facing error message for the most recent failed navigation, or + /// `nil` when the last navigation succeeded or none has occurred. + public var lastErrorMessage: String? + + /// A pending URL the representable should load, set by ``load(_:)``. The + /// view consumes it via ``consumeLoadRequest()`` and clears it so the same + /// request is not replayed on re-render. + public private(set) var loadRequest: URL? + + /// A pending history/navigation command the representable should run against + /// the `WKWebView` (back, forward, reload, stop). The view consumes it via + /// ``consumeCommand()`` and clears it so the same command runs once. + public private(set) var pendingCommand: NavigationCommand? + + /// Creates a browser surface state. + /// + /// - Parameters: + /// - id: The surface's stable identifier. + /// - initialURL: An optional URL to load when the surface first appears. + /// When provided, ``loadRequest`` and ``addressText`` are seeded from it. + public init(id: ID, initialURL: URL? = nil) { + self.id = id + self.addressText = initialURL?.absoluteString ?? "" + self.isAddressEditing = false + self.title = nil + self.currentURL = initialURL + self.isLoading = false + self.estimatedProgress = 0 + self.canGoBack = false + self.canGoForward = false + self.lastErrorMessage = nil + self.loadRequest = initialURL + } + + /// Request a navigation to `url`. Sets ``loadRequest`` for the view to pick + /// up and seeds the address bar so it reflects the target immediately. + /// + /// - Parameter url: The URL to load. + public func load(_ url: URL) { + loadRequest = url + addressText = url.absoluteString + lastErrorMessage = nil + } + + /// Resolve and load whatever is currently in the address bar, returning + /// whether a loadable URL was produced. + /// + /// - Parameter resolver: The resolver used to interpret the address text. + /// Defaults to ``BrowserURLResolver`` semantics. + /// - Returns: `true` if a URL was resolved and a load was requested. + @discardableResult + public func submitAddress(using resolve: (String) -> URL? = { BrowserURLResolver.resolve($0) }) -> Bool { + guard let url = resolve(addressText) else { return false } + load(url) + return true + } + + /// Consume the pending ``loadRequest``, returning it and clearing it so the + /// view loads each request exactly once. + /// + /// Returns `nil` without mutating when nothing is pending, so the + /// representable's `updateUIView` (which calls this on every refresh) does + /// not write observable state on no-op refreshes and trigger a re-render + /// loop while a page is loading. + /// + /// - Returns: The pending load URL, or `nil` if none is pending. + public func consumeLoadRequest() -> URL? { + guard let request = loadRequest else { return nil } + loadRequest = nil + return request + } + + /// Request a history/navigation command (back, forward, reload, stop). The + /// representable runs it against the web view and clears it. + /// + /// - Parameter command: The command to run. + public func request(_ command: NavigationCommand) { + pendingCommand = command + } + + /// Consume the pending navigation command, returning it and clearing it so + /// the view runs each command exactly once. + /// + /// Returns `nil` without mutating when nothing is pending, for the same + /// no-op-refresh reason as ``consumeLoadRequest()``. + /// + /// - Returns: The pending command, or `nil` if none is pending. + public func consumeCommand() -> NavigationCommand? { + guard let command = pendingCommand else { return nil } + pendingCommand = nil + return command + } + + /// Mark the start of a navigation: loading begins, progress resets, and any + /// prior error is cleared. + public func navigationDidStart() { + isLoading = true + estimatedProgress = 0 + lastErrorMessage = nil + } + + /// Mark a successful navigation finish: loading ends and progress completes. + public func navigationDidFinish() { + isLoading = false + estimatedProgress = 1 + } + + /// Mark a navigation failure with a user-facing message. + /// + /// - Parameter message: The error description to surface in the chrome. + public func navigationDidFail(message: String) { + isLoading = false + estimatedProgress = 0 + lastErrorMessage = message + } +} diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift new file mode 100644 index 000000000000..93473f247ffb --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserSurfaceStore.swift @@ -0,0 +1,92 @@ +public import Foundation +import Observation + +/// Owns the phone-local browser surfaces, one optional active surface per +/// workspace. +/// +/// Browser state is deliberately kept out of `MobileShellComposite` and +/// `MobileWorkspacePreview`: a terminal preview is rebuilt from the Mac on every +/// `workspace.updated` sync, so storing a browser there would clobber it on the +/// next sync. This store is the local home for browser panes; it is injected +/// into the shell UI alongside the terminal store and survives Mac re-syncs. +/// +/// Each workspace has at most one browser surface in P1 (single pane, not +/// multi-tab). Opening a browser sets the workspace's active surface; closing it +/// clears it and the UI falls back to the terminal. +@MainActor +@Observable +public final class BrowserSurfaceStore { + /// The active browser surface per workspace id, keyed by the workspace's raw + /// identifier string. Absent keys mean the workspace shows its terminal. + private var surfacesByWorkspace: [String: BrowserSurfaceState] + + /// Produces a fresh, unique surface id. Injected so tests are deterministic. + private let makeSurfaceID: () -> BrowserSurfaceState.ID + + /// The URL a freshly opened browser loads. Injected so the default is + /// configurable and tests stay hermetic. + private let defaultURL: URL? + + /// Creates a browser surface store. + /// + /// - Parameters: + /// - defaultURL: The URL a newly opened browser loads. Defaults to + /// DuckDuckGo's homepage. + /// - makeSurfaceID: A factory for unique surface ids. Defaults to a + /// UUID-backed generator. + public init( + defaultURL: URL? = URL(string: "https://duckduckgo.com/"), + makeSurfaceID: @escaping () -> BrowserSurfaceState.ID = { + BrowserSurfaceState.ID(rawValue: UUID().uuidString) + } + ) { + self.surfacesByWorkspace = [:] + self.makeSurfaceID = makeSurfaceID + self.defaultURL = defaultURL + } + + /// The active browser surface for a workspace, if one is open. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + /// - Returns: The active surface, or `nil` when the workspace shows its + /// terminal. + public func activeBrowser(for workspaceID: String) -> BrowserSurfaceState? { + surfacesByWorkspace[workspaceID] + } + + /// Whether a workspace currently has a browser pane open. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + /// - Returns: `true` if a browser surface is active for the workspace. + public func hasBrowser(for workspaceID: String) -> Bool { + surfacesByWorkspace[workspaceID] != nil + } + + /// Open (or reveal the existing) browser pane for a workspace. + /// + /// If the workspace already has a browser surface, that same surface is + /// returned so the current page is restored when switching away and back + /// (the surface's `currentURL` is reloaded into a fresh web view on + /// re-attach). In P1, full back/forward history is not preserved across + /// remounts; persisting the live WebKit session and history is P2. A new + /// surface loads ``defaultURL``. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + /// - Returns: The active browser surface for the workspace. + @discardableResult + public func openBrowser(for workspaceID: String) -> BrowserSurfaceState { + if let existing = surfacesByWorkspace[workspaceID] { + return existing + } + let surface = BrowserSurfaceState(id: makeSurfaceID(), initialURL: defaultURL) + surfacesByWorkspace[workspaceID] = surface + return surface + } + + /// Close the browser pane for a workspace, returning the UI to its terminal. + /// + /// - Parameter workspaceID: The workspace's raw identifier string. + public func closeBrowser(for workspaceID: String) { + surfacesByWorkspace.removeValue(forKey: workspaceID) + } +} diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift new file mode 100644 index 000000000000..d2f908da895a --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift @@ -0,0 +1,169 @@ +public import Foundation + +/// Turns whatever a user types in the address bar into a loadable `URL`. +/// +/// The phone address bar accepts three kinds of input, and this resolver maps +/// each to a concrete request, mirroring how a normal mobile browser omnibox +/// behaves: +/// +/// - A full URL with a scheme (`https://example.com`) loads verbatim. +/// - A bare host or path that looks like a domain (`example.com`, +/// `localhost:3000`) is treated as an `https://` URL. +/// - Anything else (free text, multiple words) becomes a web search. +/// +/// It is a pure value type with no I/O so it can be unit-tested in isolation, +/// which is where the address-bar correctness actually lives. +public enum BrowserURLResolver { + /// The default search-engine query template. `%@` is replaced with the + /// percent-encoded query. + public static let defaultSearchTemplate = "https://duckduckgo.com/?q=%@" + + /// Resolve raw address-bar text into a URL to load. + /// + /// - Parameters: + /// - input: The raw text the user submitted. + /// - searchTemplate: The search-URL template used when `input` is not a + /// URL. `%@` is replaced with the percent-encoded query. Defaults to + /// ``defaultSearchTemplate``. + /// - Returns: A URL to load, or `nil` when `input` is empty after trimming. + public static func resolve( + _ input: String, + searchTemplate: String = defaultSearchTemplate + ) -> URL? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let schemed = schemedURL(from: trimmed) { + return schemed + } + if looksLikeHost(trimmed) { + // Local dev servers (localhost, loopback, private LAN) listen on + // plain HTTP, and opening a local dev server is a central cmux + // workflow. Forcing HTTPS would break them, so default those to + // `http://` and everything else to `https://`. This matches the + // desktop browser resolver's localhost/loopback special-casing. + let scheme = isLocalHost(trimmed) ? "http" : "https" + // A bare (unbracketed) IPv6 literal must be bracketed for the URL to + // parse: `::1` -> `http://[::1]`. + let authority = needsIPv6Brackets(trimmed) ? "[\(trimmed)]" : trimmed + if let host = URL(string: "\(scheme)://\(authority)") { + return host + } + } + return searchURL(for: trimmed, template: searchTemplate) + } + + /// Whether `input` is an unbracketed IPv6 literal (two or more colons, not + /// already bracketed and without a path), so it must be wrapped in `[...]` + /// to form a valid URL authority. + private static func needsIPv6Brackets(_ input: String) -> Bool { + guard !input.hasPrefix("["), !input.contains("/") else { return false } + return input.filter { $0 == ":" }.count >= 2 + } + + /// Characters allowed unescaped when encoding a query-string *value*. + /// + /// `.urlQueryAllowed` is too permissive for a value substituted into + /// `?q=...`: it leaves the parameter separators `&`, `=`, `+`, `?`, and `#` + /// unescaped, so a search like `AT&T earnings` or `C++` would be split or + /// reinterpreted by the endpoint. Subtracting those separators preserves the + /// typed query verbatim. + private static let queryValueAllowed: CharacterSet = { + var set = CharacterSet.urlQueryAllowed + set.remove(charactersIn: "&=+?#") + return set + }() + + /// Build a search URL for free-text input. + /// + /// - Parameters: + /// - query: The user's free-text query. + /// - template: The search-URL template; `%@` is replaced with the + /// percent-encoded query (encoded as a query-string value, so query + /// separators in the input are escaped). + /// - Returns: The search URL, or `nil` if the template is malformed. + public static func searchURL(for query: String, template: String = defaultSearchTemplate) -> URL? { + let encoded = query.addingPercentEncoding( + withAllowedCharacters: queryValueAllowed + ) ?? query + return URL(string: template.replacingOccurrences(of: "%@", with: encoded)) + } + + /// A URL with an explicit, http(s)-like scheme, or `nil` if `input` has no + /// usable scheme. Schemes other than `http`/`https` are rejected so a typed + /// `file:` or `javascript:` cannot be loaded from the address bar. + private static func schemedURL(from input: String) -> URL? { + guard let components = URLComponents(string: input), + let scheme = components.scheme?.lowercased() else { + return nil + } + guard scheme == "http" || scheme == "https" else { return nil } + // A scheme with no host (`https://`) is not loadable; fall through to + // the host/search heuristics by reporting no schemed URL. + guard let host = components.host, !host.isEmpty else { return nil } + return components.url + } + + /// Whether `input` (which has no scheme) looks like a host the user wants to + /// visit rather than a search query. A token is host-like when it has no + /// spaces and contains a dot (`a.com`) or is a known local host + /// (`localhost`, optionally with a port or path). + private static func looksLikeHost(_ input: String) -> Bool { + guard !input.contains(" ") else { return false } + let host = bareHost(of: input) + if host == "localhost" { return true } + // An IPv6 literal (the bare-host extraction keeps the colons) is a host, + // e.g. `::1` or a bracketed `[::1]:3000`. + if host.filter({ $0 == ":" }).count >= 2 { return true } + // A dotted token with a non-empty label on each side of the last dot + // (so a trailing-dot or leading-dot string is not treated as a host). + guard let lastDot = host.lastIndex(of: ".") else { return false } + let afterDot = host[host.index(after: lastDot)...] + let beforeDot = host[.. Bool { + let host = bareHost(of: input).lowercased() + if host == "localhost" || host == "::1" { return true } + let octets = host.split(separator: ".", omittingEmptySubsequences: false) + guard octets.count == 4, octets.allSatisfy({ UInt8($0) != nil }) else { return false } + let values = octets.compactMap { Int($0) } + guard values.count == 4 else { return false } + if values[0] == 127 { return true } // 127.0.0.0/8 loopback + if values[0] == 10 { return true } // 10.0.0.0/8 + if values[0] == 192 && values[1] == 168 { return true } // 192.168.0.0/16 + if values[0] == 172 && (16...31).contains(values[1]) { return true } // 172.16.0.0/12 + return false + } + + /// The bare host of `input` (no scheme, no port, no path). + /// + /// Strips the path (first `/`), then the port. A bracketed IPv6 literal + /// (`[::1]:3000`) keeps the address between the brackets; an unbracketed + /// token containing multiple colons is treated as a bare IPv6 literal + /// (`::1`) so the loopback comparison can match. A single-colon token is a + /// `host:port` pair and keeps only the host. + private static func bareHost(of input: String) -> String { + let hostAndPort = input.split(separator: "/", maxSplits: 1).first.map(String.init) ?? input + if hostAndPort.hasPrefix("[") { + // Bracketed IPv6: take everything inside the brackets. + if let close = hostAndPort.firstIndex(of: "]") { + return String(hostAndPort[hostAndPort.index(after: hostAndPort.startIndex)..= 2 { + // Unbracketed multi-colon token: a bare IPv6 literal, no port. + return hostAndPort + } + // host or host:port. + return hostAndPort.split(separator: ":", maxSplits: 1).first.map(String.init) ?? hostAndPort + } +} diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift new file mode 100644 index 000000000000..48529cd3aaae --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserPane.swift @@ -0,0 +1,136 @@ +#if canImport(UIKit) +public import SwiftUI +import CmuxMobileSupport + +/// A complete phone browser pane: a navigation chrome bar (back / forward / +/// reload / address field) over a hosted `WKWebView`, plus a determinate +/// loading line. +/// +/// This is the browser sibling of the terminal surface view. It is driven +/// entirely by an `@Observable` ``BrowserSurfaceState``: the chrome reads the +/// state's flags and writes navigation commands back into it, and +/// ``MobileBrowserView`` carries those into the web view. A close action +/// returns the workspace to its terminal. +public struct MobileBrowserPane: View { + /// The browser surface state this pane drives and reflects. + @State private var state: BrowserSurfaceState + + /// Whether the address field currently has editing focus. While editing, + /// the field shows the user's in-progress text rather than the live URL. + @FocusState private var isAddressFocused: Bool + + /// Invoked when the user closes the browser pane. + private let onClose: () -> Void + + /// Creates a browser pane. + /// - Parameters: + /// - state: The browser surface state to host. + /// - onClose: Invoked when the user dismisses the pane. + public init(state: BrowserSurfaceState, onClose: @escaping () -> Void) { + _state = State(initialValue: state) + self.onClose = onClose + } + + public var body: some View { + VStack(spacing: 0) { + chromeBar + progressLine + MobileBrowserView(state: state) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color(.systemBackground)) + } + + private var chromeBar: some View { + HStack(spacing: 12) { + Button { + state.request(.goBack) + } label: { + Image(systemName: "chevron.backward") + } + .disabled(!state.canGoBack) + .accessibilityLabel(L10n.string("mobile.browser.back", defaultValue: "Back")) + .accessibilityIdentifier("MobileBrowserBackButton") + + Button { + state.request(.goForward) + } label: { + Image(systemName: "chevron.forward") + } + .disabled(!state.canGoForward) + .accessibilityLabel(L10n.string("mobile.browser.forward", defaultValue: "Forward")) + .accessibilityIdentifier("MobileBrowserForwardButton") + + addressField + + reloadOrStopButton + + Button(action: onClose) { + Image(systemName: "xmark") + } + .accessibilityLabel(L10n.string("mobile.browser.close", defaultValue: "Close Browser")) + .accessibilityIdentifier("MobileBrowserCloseButton") + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + } + + private var addressField: some View { + TextField( + L10n.string("mobile.browser.addressPlaceholder", defaultValue: "Search or enter address"), + text: $state.addressText + ) + .textFieldStyle(.roundedBorder) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .keyboardType(.webSearch) + .submitLabel(.go) + .focused($isAddressFocused) + .onChange(of: isAddressFocused) { _, focused in + // Mirror editing focus into the state so the web view's URL observer + // does not overwrite in-progress typing (see `isAddressEditing`). + state.isAddressEditing = focused + } + .onSubmit { + if state.submitAddress() { + isAddressFocused = false + } + } + .accessibilityIdentifier("MobileBrowserAddressField") + } + + @ViewBuilder + private var reloadOrStopButton: some View { + if state.isLoading { + Button { + state.request(.stopLoading) + } label: { + Image(systemName: "xmark.circle") + } + .accessibilityLabel(L10n.string("mobile.browser.stop", defaultValue: "Stop")) + .accessibilityIdentifier("MobileBrowserStopButton") + } else { + Button { + state.request(.reload) + } label: { + Image(systemName: "arrow.clockwise") + } + .accessibilityLabel(L10n.string("mobile.browser.reload", defaultValue: "Reload")) + .accessibilityIdentifier("MobileBrowserReloadButton") + } + } + + @ViewBuilder + private var progressLine: some View { + if state.isLoading { + ProgressView(value: state.estimatedProgress) + .progressViewStyle(.linear) + .frame(height: 2) + .accessibilityIdentifier("MobileBrowserProgress") + } else { + Color.clear.frame(height: 2) + } + } +} +#endif diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift new file mode 100644 index 000000000000..55257136c839 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/MobileBrowserView.swift @@ -0,0 +1,228 @@ +#if canImport(UIKit) +public import SwiftUI +public import UIKit +public import WebKit + +/// SwiftUI wrapper that hosts a single `WKWebView` for a ``BrowserSurfaceState``. +/// +/// This is the browser sibling of the terminal's `GhosttySurfaceRepresentable`: +/// a `UIViewRepresentable` whose coordinator owns the web view, observes its +/// navigation key paths, and mirrors them into the `@Observable` surface state +/// so the SwiftUI chrome (address bar, progress, back/forward) stays in sync. +/// +/// Loading progress and navigation flags come from `NSKeyValueObservation` on +/// the web view plus `WKNavigationDelegate` callbacks rather than Combine, to +/// fit the `@Observable` model and avoid `ObservableObject`. +public struct MobileBrowserView: UIViewRepresentable { + /// The state this view drives and reflects. + public let state: BrowserSurfaceState + + /// Creates a browser view bound to a surface state. + /// - Parameter state: The browser surface state to host. + public init(state: BrowserSurfaceState) { + self.state = state + } + + /// Builds the coordinator that owns the web view and its observations. + /// - Returns: A new ``Coordinator``. + public func makeCoordinator() -> Coordinator { + Coordinator(state: state) + } + + /// Creates and configures the hosted `WKWebView`. + /// - Parameter context: The representable context carrying the coordinator. + /// - Returns: The configured web view. + public func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + // Default persistent data store: cookies/localStorage persist on the + // phone across launches. Cross-device sync with the Mac is P2. + configuration.websiteDataStore = .default() + configuration.allowsInlineMediaPlayback = true + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + context.coordinator.attach(webView: webView) + return webView + } + + /// Pushes any pending load request and navigation command from the state + /// into the web view. + /// - Parameters: + /// - uiView: The hosted web view. + /// - context: The representable context carrying the coordinator. + public func updateUIView(_ uiView: WKWebView, context: Context) { + context.coordinator.applyPendingWork() + } + + /// Tears down the coordinator's observations and web-view delegate. + /// - Parameters: + /// - uiView: The hosted web view. + /// - coordinator: The coordinator to detach. + public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { + coordinator.detach() + } + + /// Owns the `WKWebView`, observes its navigation key paths, and bridges + /// navigation callbacks into the `@Observable` ``BrowserSurfaceState``. + @MainActor + public final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { + private let state: BrowserSurfaceState + private weak var webView: WKWebView? + private var observations: [NSKeyValueObservation] = [] + + /// Creates a coordinator for a surface state. + /// - Parameter state: The surface state to mirror web-view changes into. + public init(state: BrowserSurfaceState) { + self.state = state + super.init() + } + + /// Binds the coordinator to a web view: registers key-value observations + /// and kicks off the first pending load. + /// - Parameter webView: The web view to observe and drive. + func attach(webView: WKWebView) { + self.webView = webView + observe(webView) + // A surface can be re-attached to a fresh WKWebView when SwiftUI + // remounts the representable (switching workspaces, hiding/showing + // the browser). The surface state survives, but the web view does + // not, so restore the last committed URL on re-attach to honor the + // "current page is restored on return" promise. First mount already + // has a pending initial-URL load, so guard against a double-load. + let hadPendingLoad = state.loadRequest != nil + applyPendingWork() + if !hadPendingLoad, webView.url == nil, let restore = state.currentURL { + webView.load(URLRequest(url: restore)) + } + } + + /// Runs any pending load request and navigation command from the state + /// against the web view. + func applyPendingWork() { + guard let webView else { return } + if let url = state.consumeLoadRequest() { + webView.load(URLRequest(url: url)) + } + if let command = state.consumeCommand() { + run(command, on: webView) + } + } + + private func run(_ command: BrowserSurfaceState.NavigationCommand, on webView: WKWebView) { + switch command { + case .goBack: + webView.goBack() + case .goForward: + webView.goForward() + case .reload: + webView.reload() + case .stopLoading: + webView.stopLoading() + } + } + + /// Cancels all observations and releases the web view. Called on + /// dismantle so the surface leaves no dangling KVO registrations. + func detach() { + observations.forEach { $0.invalidate() } + observations.removeAll() + webView?.navigationDelegate = nil + webView?.uiDelegate = nil + webView = nil + } + + private func observe(_ webView: WKWebView) { + // Each observer mirrors one web-view property into the @Observable + // state on the main actor. `options: [.initial]` is intentionally + // omitted so the seeded state is not overwritten before first load. + observations = [ + webView.observe(\.estimatedProgress) { [state] webView, _ in + MainActor.assumeIsolated { + state.estimatedProgress = webView.estimatedProgress + } + }, + webView.observe(\.title) { [state] webView, _ in + MainActor.assumeIsolated { + if let title = webView.title, !title.isEmpty { + state.title = title + } + } + }, + webView.observe(\.url) { [state] webView, _ in + MainActor.assumeIsolated { + state.currentURL = webView.url + // Do not clobber the user's in-progress typing: only + // mirror the live URL into the address bar when the user + // is not editing it. + if let url = webView.url, !state.isAddressEditing { + state.addressText = url.absoluteString + } + } + }, + webView.observe(\.canGoBack) { [state] webView, _ in + MainActor.assumeIsolated { state.canGoBack = webView.canGoBack } + }, + webView.observe(\.canGoForward) { [state] webView, _ in + MainActor.assumeIsolated { state.canGoForward = webView.canGoForward } + }, + ] + } + + // MARK: - WKNavigationDelegate + + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + state.navigationDidStart() + } + + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + state.navigationDidFinish() + if let title = webView.title, !title.isEmpty { + state.title = title + } + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + failNavigation(with: error) + } + + public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: any Error) { + failNavigation(with: error) + } + + private func failNavigation(with error: any Error) { + // A cancelled load reports `NSURLErrorCancelled`. This is not a + // failure to surface; it happens on a user stop AND when a new + // navigation replaces an in-flight one. Mirror the web view's real + // `isLoading` rather than forcing `false`, so the chrome stays in the + // loading state when a replacement navigation is still in flight. + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { + state.isLoading = webView?.isLoading ?? false + if !state.isLoading { state.estimatedProgress = 0 } + return + } + state.navigationDidFail(message: error.localizedDescription) + } + + // MARK: - WKUIDelegate + + public func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + // P1 is a single-pane browser with no tabs, so `target="_blank"` / + // `window.open` links (which arrive with a nil `targetFrame`) would + // otherwise be silently dropped. Load them in the current web view + // instead so external/doc/auth links still navigate. Returning nil + // tells WebKit not to create a new web view. + if navigationAction.targetFrame == nil { + webView.load(navigationAction.request) + } + return nil + } + } +} +#endif diff --git a/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift new file mode 100644 index 000000000000..aa5fa708ee70 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStateTests.swift @@ -0,0 +1,107 @@ +import Foundation +import Testing + +@testable import CmuxMobileBrowser + +/// Pure nav-state and loading transitions for a browser surface, exercised +/// without a `WKWebView`. +@MainActor +@Suite struct BrowserSurfaceStateTests { + private func makeState(initialURL: URL? = nil) -> BrowserSurfaceState { + BrowserSurfaceState(id: .init(rawValue: "test"), initialURL: initialURL) + } + + @Test func initialURLSeedsAddressAndLoadRequest() { + let url = URL(string: "https://example.com")! + let state = makeState(initialURL: url) + #expect(state.addressText == "https://example.com") + #expect(state.currentURL == url) + #expect(state.consumeLoadRequest() == url) + // Consumed exactly once. + #expect(state.consumeLoadRequest() == nil) + } + + @Test func emptyInitialStateHasNoPendingWork() { + let state = makeState() + #expect(state.addressText.isEmpty) + #expect(state.consumeLoadRequest() == nil) + #expect(state.consumeCommand() == nil) + #expect(state.isLoading == false) + #expect(state.canGoBack == false) + #expect(state.canGoForward == false) + #expect(state.isAddressEditing == false) + } + + @Test func consumeHelpersAreIdempotentWhenEmpty() { + // Calling the consumers repeatedly with nothing pending must keep + // returning nil; the representable calls these on every refresh and they + // must not churn observable state on no-op refreshes. + let state = makeState() + for _ in 0..<3 { + #expect(state.consumeLoadRequest() == nil) + #expect(state.consumeCommand() == nil) + } + } + + @Test func loadSetsRequestAndAddressAndClearsError() { + let state = makeState() + state.navigationDidFail(message: "boom") + let url = URL(string: "https://cmux.dev")! + state.load(url) + #expect(state.addressText == "https://cmux.dev") + #expect(state.lastErrorMessage == nil) + #expect(state.consumeLoadRequest() == url) + } + + @Test func submitAddressResolvesAndRequestsLoad() { + let state = makeState() + state.addressText = "example.com" + let didLoad = state.submitAddress() + #expect(didLoad) + #expect(state.consumeLoadRequest()?.host == "example.com") + } + + @Test func submitAddressReturnsFalseForEmpty() { + let state = makeState() + state.addressText = " " + #expect(state.submitAddress() == false) + #expect(state.consumeLoadRequest() == nil) + } + + @Test func navigationLifecycleTransitions() { + let state = makeState() + + state.navigationDidStart() + #expect(state.isLoading) + #expect(state.estimatedProgress == 0) + #expect(state.lastErrorMessage == nil) + + state.estimatedProgress = 0.5 + state.navigationDidFinish() + #expect(state.isLoading == false) + #expect(state.estimatedProgress == 1) + } + + @Test func navigationFailureSurfacesMessageAndStopsLoading() { + let state = makeState() + state.navigationDidStart() + state.navigationDidFail(message: "no network") + #expect(state.isLoading == false) + #expect(state.estimatedProgress == 0) + #expect(state.lastErrorMessage == "no network") + } + + @Test func commandQueueConsumedOnce() { + let state = makeState() + state.request(.reload) + #expect(state.consumeCommand() == .reload) + #expect(state.consumeCommand() == nil) + } + + @Test func laterCommandReplacesPendingCommand() { + let state = makeState() + state.request(.goBack) + state.request(.goForward) + #expect(state.consumeCommand() == .goForward) + } +} diff --git a/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift new file mode 100644 index 000000000000..cff691a06775 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserSurfaceStoreTests.swift @@ -0,0 +1,72 @@ +import Foundation +import Testing + +@testable import CmuxMobileBrowser + +/// The store owns at most one browser surface per workspace and survives Mac +/// re-syncs. These guard the open/reveal/close semantics the shell UI relies on. +@MainActor +@Suite struct BrowserSurfaceStoreTests { + private func makeStore() -> BrowserSurfaceStore { + var counter = 0 + return BrowserSurfaceStore( + defaultURL: URL(string: "https://duckduckgo.com/"), + makeSurfaceID: { + counter += 1 + return BrowserSurfaceState.ID(rawValue: "surface-\(counter)") + } + ) + } + + @Test func noBrowserByDefault() { + let store = makeStore() + #expect(store.hasBrowser(for: "ws-1") == false) + #expect(store.activeBrowser(for: "ws-1") == nil) + } + + @Test func openBrowserCreatesSurfaceForWorkspace() { + let store = makeStore() + let surface = store.openBrowser(for: "ws-1") + #expect(store.hasBrowser(for: "ws-1")) + #expect(store.activeBrowser(for: "ws-1") === surface) + #expect(surface.id == .init(rawValue: "surface-1")) + #expect(surface.consumeLoadRequest()?.absoluteString == "https://duckduckgo.com/") + } + + @Test func openBrowserTwiceRevealsSameSurface() { + let store = makeStore() + let first = store.openBrowser(for: "ws-1") + let second = store.openBrowser(for: "ws-1") + // Same instance, so the current page is restored when switching away and + // back (the surface's currentURL is reloaded on re-attach). Full live + // WebKit history persistence across remounts is P2. + #expect(first === second) + } + + @Test func browsersAreScopedPerWorkspace() { + let store = makeStore() + let a = store.openBrowser(for: "ws-1") + let b = store.openBrowser(for: "ws-2") + #expect(a !== b) + #expect(store.activeBrowser(for: "ws-1") === a) + #expect(store.activeBrowser(for: "ws-2") === b) + } + + @Test func closeBrowserClearsOnlyThatWorkspace() { + let store = makeStore() + _ = store.openBrowser(for: "ws-1") + _ = store.openBrowser(for: "ws-2") + store.closeBrowser(for: "ws-1") + #expect(store.hasBrowser(for: "ws-1") == false) + #expect(store.hasBrowser(for: "ws-2")) + } + + @Test func reopenAfterCloseMakesFreshSurface() { + let store = makeStore() + let first = store.openBrowser(for: "ws-1") + store.closeBrowser(for: "ws-1") + let second = store.openBrowser(for: "ws-1") + #expect(first !== second) + #expect(second.id == .init(rawValue: "surface-2")) + } +} diff --git a/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift new file mode 100644 index 000000000000..d58d264a9f42 --- /dev/null +++ b/Packages/CmuxMobileBrowser/Tests/CmuxMobileBrowserTests/BrowserURLResolverTests.swift @@ -0,0 +1,139 @@ +import Foundation +import Testing + +@testable import CmuxMobileBrowser + +/// The address bar maps three input shapes (full URL, bare host, free text) to +/// concrete loads. These guard that mapping, which is where omnibox correctness +/// lives. +@Suite struct BrowserURLResolverTests { + @Test func emptyOrWhitespaceResolvesToNil() { + #expect(BrowserURLResolver.resolve("") == nil) + #expect(BrowserURLResolver.resolve(" ") == nil) + #expect(BrowserURLResolver.resolve("\n\t") == nil) + } + + @Test func fullHTTPSURLLoadsVerbatim() { + let url = BrowserURLResolver.resolve("https://example.com/path?q=1") + #expect(url?.absoluteString == "https://example.com/path?q=1") + } + + @Test func httpSchemeIsPreserved() { + let url = BrowserURLResolver.resolve("http://example.com") + #expect(url?.scheme == "http") + #expect(url?.host == "example.com") + } + + @Test func bareDomainGetsHTTPSScheme() { + let url = BrowserURLResolver.resolve("example.com") + #expect(url?.scheme == "https") + #expect(url?.host == "example.com") + } + + @Test func bareDomainWithPathGetsHTTPSScheme() { + let url = BrowserURLResolver.resolve("example.com/docs/page") + #expect(url?.scheme == "https") + #expect(url?.host == "example.com") + #expect(url?.path == "/docs/page") + } + + @Test func localhostWithPortDefaultsToHTTP() { + // Local dev servers listen on plain HTTP; forcing HTTPS would break the + // common "open my local dev server" cmux workflow. + let url = BrowserURLResolver.resolve("localhost:3000") + #expect(url?.scheme == "http") + #expect(url?.host == "localhost") + #expect(url?.port == 3000) + } + + @Test func loopbackIPDefaultsToHTTP() { + let url = BrowserURLResolver.resolve("127.0.0.1:8080") + #expect(url?.scheme == "http") + #expect(url?.host == "127.0.0.1") + #expect(url?.port == 8080) + } + + @Test func privateLANAddressesDefaultToHTTP() { + for host in ["192.168.1.10", "10.0.0.5", "172.16.0.1"] { + let url = BrowserURLResolver.resolve(host) + #expect(url?.scheme == "http", "expected http for \(host)") + #expect(url?.host == host) + } + } + + @Test func publicIPLikeAddressDefaultsToHTTPS() { + // A non-private dotted-quad is treated as a normal host: HTTPS. + let url = BrowserURLResolver.resolve("8.8.8.8") + #expect(url?.scheme == "https") + #expect(url?.host == "8.8.8.8") + } + + @Test func bareIPv6LoopbackIsBracketedHTTP() { + // `::1` must be recognized as a local host (not a search) and bracketed. + let url = BrowserURLResolver.resolve("::1") + #expect(url?.scheme == "http") + #expect(url?.absoluteString == "http://[::1]") + } + + @Test func bracketedIPv6LoopbackWithPortIsHTTP() { + let url = BrowserURLResolver.resolve("[::1]:3000") + #expect(url?.scheme == "http") + #expect(url?.port == 3000) + } + + @Test func multiWordInputBecomesSearch() { + let url = BrowserURLResolver.resolve("how to write swift") + #expect(url?.host == "duckduckgo.com") + #expect(url?.query?.contains("how") == true) + // Spaces must be percent-encoded, never left raw. + #expect(url?.absoluteString.contains(" ") == false) + } + + @Test func singleWordWithoutDotBecomesSearch() { + let url = BrowserURLResolver.resolve("swift") + #expect(url?.host == "duckduckgo.com") + #expect(url?.query?.contains("swift") == true) + } + + @Test func leadingWhitespaceIsTrimmedBeforeResolving() { + let url = BrowserURLResolver.resolve(" example.com ") + #expect(url?.host == "example.com") + } + + @Test func nonHTTPSchemeFallsBackToSearch() { + // A typed `file:`/`javascript:` must not load as-is; it is treated as a + // query so the address bar can never load a non-web scheme. + let url = BrowserURLResolver.resolve("javascript:alert(1)") + #expect(url?.host == "duckduckgo.com") + } + + @Test func searchEscapesQuerySeparators() { + // `&`, `=`, `+`, `#`, `?` in the typed query must be percent-escaped so + // the search endpoint receives the whole string as one value rather than + // splitting it into extra parameters. + let url = BrowserURLResolver.resolve("AT&T earnings") + #expect(url?.host == "duckduckgo.com") + let raw = url?.absoluteString ?? "" + // Exactly one `&`-free query: the literal `&` is encoded as %26. + #expect(raw.contains("%26")) + // The query component decodes back to the original input. + #expect(url?.query?.contains("q=") == true) + let plusURL = BrowserURLResolver.resolve("C++ tutorial") + #expect(plusURL?.absoluteString.contains("%2B%2B") == true) + } + + @Test func customSearchTemplateIsUsed() { + let url = BrowserURLResolver.resolve( + "hello world", + searchTemplate: "https://search.example/q=%@" + ) + #expect(url?.host == "search.example") + #expect(url?.absoluteString.contains("hello") == true) + } + + @Test func trailingDotTokenIsNotAHost() { + // `foo.` has an empty label after the last dot, so it is a search query. + let url = BrowserURLResolver.resolve("foo.") + #expect(url?.host == "duckduckgo.com") + } +} diff --git a/Packages/CmuxMobileShellUI/Package.swift b/Packages/CmuxMobileShellUI/Package.swift index b9c98f180eeb..64da0b465ffd 100644 --- a/Packages/CmuxMobileShellUI/Package.swift +++ b/Packages/CmuxMobileShellUI/Package.swift @@ -16,6 +16,7 @@ let package = Package( dependencies: [ .package(path: "../CMUXMobileCore"), .package(path: "../CmuxAuthRuntime"), + .package(path: "../CmuxMobileBrowser"), .package(path: "../CmuxMobileCamera"), .package(path: "../CmuxMobileDiagnostics"), .package(path: "../CmuxMobilePairedMac"), @@ -33,6 +34,7 @@ let package = Package( dependencies: [ "CMUXMobileCore", "CmuxAuthRuntime", + "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", "CmuxMobilePairedMac", diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift index ed1811519a85..82162a6eeeb1 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileAppView.swift @@ -1,3 +1,4 @@ +import CmuxMobileBrowser import CmuxMobileShell import SwiftUI #if os(iOS) @@ -8,12 +9,23 @@ import AppKit public struct CMUXMobileAppView: View { @State private var store: CMUXMobileShellStore + /// Phone-local browser surfaces, owned for the app's lifetime and injected + /// into the environment so the workspace detail view can present a browser + /// pane without threading the store through every intermediate view. Browser + /// state lives here (not in the shell store) because, unlike terminals, it + /// has no Mac-side counterpart and must survive `workspace.updated` re-syncs. + @State private var browserStore: BrowserSurfaceStore - public init(store: CMUXMobileShellStore = .preview()) { + public init( + store: CMUXMobileShellStore = .preview(), + browserStore: BrowserSurfaceStore = BrowserSurfaceStore() + ) { _store = State(initialValue: store) + _browserStore = State(initialValue: browserStore) } public var body: some View { CMUXMobileRootView(store: store) + .environment(browserStore) } } diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 0f53bb8ed04d..31c121ae91b4 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -1,3 +1,4 @@ +import CmuxMobileBrowser import CmuxMobileDiagnostics import CmuxMobileShell import CmuxMobileShellModel @@ -21,6 +22,10 @@ struct WorkspaceDetailView: View { let reportTerminalViewport: (MobileWorkspacePreview.ID, MobileTerminalPreview.ID, MobileTerminalViewportSize) -> Void let sendTerminalInput: (String) -> Void let safeAreaContext: MobileTerminalSafeAreaContext + /// Phone-local browser surfaces, injected from the app root. When this + /// workspace has an active browser surface the detail view presents a + /// browser pane in place of the terminal; otherwise it shows the terminal. + @Environment(BrowserSurfaceStore.self) private var browserStore #if DEBUG && canImport(UIKit) @State private var isFeedbackComposerPresented = false @State private var feedbackText = "" @@ -31,9 +36,46 @@ struct WorkspaceDetailView: View { workspace.terminals.first { $0.id == store.selectedTerminalID } ?? workspace.terminals.first } + /// The active browser surface for this workspace, when a browser pane is open. + private var activeBrowser: BrowserSurfaceState? { + browserStore.activeBrowser(for: workspace.id.rawValue) + } + var body: some View { + #if os(iOS) + if let browser = activeBrowser { + browserContent(browser) + } else { + detailContent() + } + #else detailContent() + #endif + } + + #if os(iOS) + /// The browser pane shown when this workspace has an active browser surface. + /// It carries its own navigation chrome, so it does not get the terminal's + /// keyboard/safe-area handling. Closing returns to the terminal. + @ViewBuilder + private func browserContent(_ browser: BrowserSurfaceState) -> some View { + MobileBrowserPane( + state: browser, + onClose: { browserStore.closeBrowser(for: workspace.id.rawValue) } + ) + // Key on the surface id so switching/reopening rebuilds the WKWebView. + .id(browser.id.rawValue) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .navigationTitle(browser.title ?? workspace.name) + .mobileTerminalNavigationChrome() + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + newWorkspaceToolbarButton + terminalPickerToolbarButton + } + } } + #endif private func detailContent() -> some View { // `GhosttySurfaceView` owns the bottom accessory bar: it docks the @@ -165,7 +207,9 @@ struct WorkspaceDetailView: View { } label: { Label( terminal.name, - systemImage: terminal.id == selectedTerminal?.id ? "checkmark.circle.fill" : "terminal" + systemImage: terminal.id == selectedTerminal?.id && activeBrowser == nil + ? "checkmark.circle.fill" + : "terminal" ) } .accessibilityIdentifier("MobileTerminalMenuItem-\(terminal.id.rawValue)") @@ -182,6 +226,14 @@ struct WorkspaceDetailView: View { Label(L10n.string("mobile.terminal.new", defaultValue: "New Terminal"), systemImage: "plus") } .accessibilityIdentifier("MobileNewTerminalMenuItem") + + Button(action: openBrowserFromToolbar) { + Label( + L10n.string("mobile.browser.new", defaultValue: "New Browser"), + systemImage: activeBrowser == nil ? "globe" : "checkmark.circle.fill" + ) + } + .accessibilityIdentifier("MobileNewBrowserMenuItem") } #if DEBUG && canImport(UIKit) @@ -282,11 +334,26 @@ struct WorkspaceDetailView: View { private func createTerminalFromToolbar() { dismissTerminalKeyboardForChrome() + // Creating a terminal from the (shared) chrome must surface it. If a + // browser pane is up, close it so `body` leaves the browser branch and + // shows the new terminal instead of staying on the browser. + browserStore.closeBrowser(for: workspace.id.rawValue) createTerminal() } + private func openBrowserFromToolbar() { + dismissTerminalKeyboardForChrome() + // Opens (or reveals the existing) browser pane for this workspace. The + // detail view flips to the browser because `activeBrowser` becomes + // non-nil; the picker shows a check next to "New Browser" while it is up. + browserStore.openBrowser(for: workspace.id.rawValue) + } + private func selectTerminalFromPicker(_ terminalID: MobileTerminalPreview.ID) { dismissTerminalKeyboardForChrome() + // Choosing a terminal returns from the browser pane (if up) to the + // terminal. Closing the browser is enough to flip the detail view back. + browserStore.closeBrowser(for: workspace.id.rawValue) // Switching from the picker is chrome, not a typing intent, so the // newly-selected surface must not grab the keyboard on attach. The // store suppresses the target's autofocus (and is a no-op when it is diff --git a/ios/Config/Info.plist b/ios/Config/Info.plist index 094d437ee342..4543be5a47bb 100644 --- a/ios/Config/Info.plist +++ b/ios/Config/Info.plist @@ -55,6 +55,17 @@ Scan cmux pairing QR codes from your Mac terminal. NSLocalNetworkUsageDescription Connect to your Mac on the local network for cmux mobile pairing and terminal sync. + NSAppTransportSecurity + + + NSAllowsArbitraryLoadsInWebContent + + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 82dc3e9e903f..67a99ae7c6ca 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3638,6 +3638,125 @@ } } } + }, + "mobile.browser.addressPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Search or enter address" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "検索またはアドレスを入力" + } + } + } + }, + "mobile.browser.back": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "戻る" + } + } + } + }, + "mobile.browser.close": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを閉じる" + } + } + } + }, + "mobile.browser.forward": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Forward" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "進む" + } + } + } + }, + "mobile.browser.new": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ブラウザ" + } + } + } + }, + "mobile.browser.reload": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reload" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再読み込み" + } + } + } + }, + "mobile.browser.stop": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + } } }, "version": "1.0" diff --git a/ios/cmuxPackage/Package.swift b/ios/cmuxPackage/Package.swift index d0d5bb1d1175..82a7a20dc90c 100644 --- a/ios/cmuxPackage/Package.swift +++ b/ios/cmuxPackage/Package.swift @@ -25,6 +25,7 @@ let package = Package( .package(path: "../../Packages/CmuxAuthRuntime"), .package(path: "../../Packages/CMUXMobileCore"), .package(path: "../../Packages/CmuxMobileAnalytics"), + .package(path: "../../Packages/CmuxMobileBrowser"), .package(path: "../../Packages/CmuxMobileCamera"), .package(path: "../../Packages/CmuxMobileDiagnostics"), .package(path: "../../Packages/CmuxMobilePairedMac"), @@ -47,6 +48,7 @@ let package = Package( "CmuxAuthRuntime", "CMUXMobileCore", "CmuxMobileAnalytics", + "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", "CmuxMobilePairedMac", @@ -74,6 +76,7 @@ let package = Package( "CmuxAuthRuntime", "CMUXMobileCore", "CmuxMobileAnalytics", + "CmuxMobileBrowser", "CmuxMobileCamera", "CmuxMobileDiagnostics", "CmuxMobilePairedMac", From 4afe97d6e1d2c4702756d813e6ed96f0033139eb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 10 Jun 2026 03:30:49 -0700 Subject: [PATCH 08/11] Make BrowserURLResolver an uninstantiable struct per package conventions --- .../Sources/CmuxMobileBrowser/BrowserURLResolver.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift index d2f908da895a..91daf909e377 100644 --- a/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift +++ b/Packages/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift @@ -13,11 +13,15 @@ public import Foundation /// /// It is a pure value type with no I/O so it can be unit-tested in isolation, /// which is where the address-bar correctness actually lives. -public enum BrowserURLResolver { +public struct BrowserURLResolver { /// The default search-engine query template. `%@` is replaced with the /// percent-encoded query. public static let defaultSearchTemplate = "https://duckduckgo.com/?q=%@" + /// The resolver is a unit of pure static functions; it is never + /// instantiated. + private init() {} + /// Resolve raw address-bar text into a URL to load. /// /// - Parameters: From 427b389ac08cfe52e76d29a7642e5b4aef6902cc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 10 Jun 2026 03:52:37 -0700 Subject: [PATCH 09/11] chore: refresh MobileShellComposite file length budget for device-tree growth --- .github/swift-file-length-budget.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 645bed515dd3..414f09d7b75f 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -30,7 +30,7 @@ 3699 cmuxTests/CLIGenericHookPersistenceTests.swift 3396 Sources/CmuxConfig.swift 3316 cmuxTests/TabManagerSessionSnapshotTests.swift -3255 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +3468 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 3202 Sources/Update/UpdateTitlebarAccessory.swift 2953 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 2877 Sources/SessionIndexView.swift From cb188c5cbeabeb49c93853cba420cb9e2a07d608 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:40:43 -0700 Subject: [PATCH 10/11] Guard authRejected registry blanking on the requesting user still being current Mirrors the .ok path's account-switch guard: a stale 401 from a signed-out session that lands after a different user signed in no longer blanks the new user's device tree. Addresses the Greptile P1 on the PR. Co-Authored-By: Claude Fable 5 --- .../Sources/CmuxMobileShell/MobileShellComposite.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 29df5db30f16..e77dacbc6c9d 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -1152,8 +1152,13 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // The registry is team-scoped and rejected the call on auth/scope // grounds (401/403): the cached list may be another scope's data, so // clear it. The tree falls back to local paired Macs via - // `deviceTreeDevices`, so the sheet stays usable. - registryDevices = [] + // `deviceTreeDevices`, so the sheet stays usable. Guarded on the + // requesting user still being current (mirroring the `.ok` path): + // a stale 401 from a signed-out session that lands after a + // different user signed in must not blank the new user's tree. + if identityProvider?.currentUserID == requestingUserID { + registryDevices = [] + } return case .transientFailure: // Network blip / 5xx / malformed body: keep what we have rather than From a0d6660655d7be7f122c8064befa8587c463512e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:28:21 -0700 Subject: [PATCH 11/11] chore: refresh file-length budget for the authRejected guard growth Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 0cea45b87858..7493c504e0de 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift -22074 Sources/TerminalController.swift +22073 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift 18011 Sources/AppDelegate.swift @@ -28,7 +28,7 @@ 3937 Sources/Feed/FeedPanelView.swift 3760 cmuxTests/TabManagerUnitTests.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift -3522 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +3527 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 3396 Sources/CmuxConfig.swift 3316 cmuxTests/TabManagerSessionSnapshotTests.swift 3202 Sources/Update/UpdateTitlebarAccessory.swift