diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a4abc2396b77..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,9 +28,9 @@ 3937 Sources/Feed/FeedPanelView.swift 3760 cmuxTests/TabManagerUnitTests.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift +3527 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 3396 Sources/CmuxConfig.swift 3316 cmuxTests/TabManagerSessionSnapshotTests.swift -3309 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 3202 Sources/Update/UpdateTitlebarAccessory.swift 2953 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 2877 Sources/SessionIndexView.swift diff --git a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift index 38c98727075c..a4bfcdcee262 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,32 @@ 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. 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 de2debb3cd7f..949d70da7afa 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,120 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return Self.routes(forMacDeviceID: macDeviceID, in: data) } + 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 .transientFailure + } + let data: Data + do { + let (responseData, response) = try await session.data(for: request) + 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 .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 .ok(devices) + } + // 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 7dae1083d22f..e77dacbc6c9d 100644 --- a/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -456,6 +456,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 @@ -1083,6 +1086,221 @@ 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. + /// + /// 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 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 nil + } + + /// 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 + } + // 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 + 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. 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 + // blanking a populated tree on a transient failure. + return + } + // 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 + } + 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 + } + } + + /// 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. + /// + /// 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. + /// + /// 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. + 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 + } + // The currently-active Mac to fall back to if the connect fails, so the + // 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 + // 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 { + // 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-") { + 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..00e01f1787fe --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeRows.swift @@ -0,0 +1,219 @@ +#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 (non-zero only for the active + /// instance, since the registry carries routes, not workspaces). + let workspaceCount: Int + /// Whether this instance is the build the live connection currently targets + /// (matched by route). Only the active instance shows live workspaces; other + /// tags on the same device offer a Connect affordance. + let isActiveInstance: Bool +} + +/// A device (Mac/host) row: name, platform icon, and live-or-last-seen state, +/// with a disclosure chevron to reveal its tagged builds. +struct DeviceTreeDeviceRow: View { + let device: DeviceTreeDeviceSnapshot + let isExpanded: Bool + let setExpanded: (Bool) -> Void + + var body: some View { + Button { + setExpanded(!isExpanded) + } label: { + HStack(spacing: 12) { + Image(systemName: chevronSymbol) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(width: 12) + Image(systemName: platformSymbol) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(device.title) + .foregroundStyle(.primary) + Text(statusLine) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 8) + if let liveStatus = device.liveStatus { + Image(systemName: liveStatus.symbolName) + .foregroundStyle(liveStatus.tintColor) + .accessibilityLabel(liveStatus.label) + } + } + .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.isActiveInstance { + return L10n.terminalCountWorkspaces(instance.workspaceCount) + } + if !instance.hasRoutes { + return L10n.string("mobile.deviceTree.noRoutes", defaultValue: "Not reachable") + } + let relative = instance.lastSeenAt.formatted(.relative(presentation: .named)) + return String( + format: L10n.string("mobile.deviceTree.lastSeenFormat", defaultValue: "Last seen %@"), + relative + ) + } +} + +/// A leaf placeholder shown when an expanded instance has no visible workspaces: +/// either it is not the connected build (offer Connect) or it is connected but +/// has no workspaces yet. +struct DeviceTreeWorkspacePlaceholderRow: View { + let isActiveInstance: Bool + let hasRoutes: Bool + let connect: (() -> Void)? + + var body: some View { + HStack(spacing: 12) { + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + if let connect, !isActiveInstance { + Button { + connect() + } label: { + Text(L10n.string("mobile.deviceTree.connectToView", defaultValue: "Connect to view")) + .font(.caption.weight(.semibold)) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .listRowInsets(EdgeInsets(top: 4, leading: 36, bottom: 4, trailing: 12)) + .accessibilityIdentifier("MobileDeviceTreeWorkspacePlaceholder") + } + + private var message: String { + if isActiveInstance { + return L10n.string("mobile.deviceTree.noWorkspaces", defaultValue: "No workspaces yet") + } + if !hasRoutes { + return L10n.string("mobile.deviceTree.noRoutes", defaultValue: "Not reachable") + } + return L10n.string( + "mobile.deviceTree.connectToSeeWorkspaces", + defaultValue: "Connect to this build to see its workspaces" + ) + } +} +#endif diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift new file mode 100644 index 000000000000..7bf099af6493 --- /dev/null +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/DeviceTreeView.swift @@ -0,0 +1,270 @@ +#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. Sourced from ``CMUXMobileShellStore/deviceTreeDevices`` + /// so it falls back to locally paired Macs when the registry is unavailable. + private var controllableDevices: [RegistryDevice] { + store.deviceTreeDevices.filter(\.isControllableHost) + } + + 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.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() + } + } + .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). + // 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 { + 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) + // 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( + tag: instance.tag, + lastSeenAt: instance.lastSeenAt, + hasRoutes: instance.hasRoutes, + workspaceCount: workspaces.count, + isActiveInstance: isActiveInstance + ), + isExpanded: expansion.isExpanded(expansionID), + setExpanded: { expanded in setExpanded(expansionID, expanded) }, + connect: connect + ) + + if expansion.isExpanded(expansionID) { + if workspaces.isEmpty { + DeviceTreeWorkspacePlaceholderRow( + isActiveInstance: isActiveInstance, + hasRoutes: instance.hasRoutes, + connect: connect + ) + } 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 + ) + ) + } + } + } + + /// Whether this instance is the build the live connection currently targets, + /// matched by route identity (the live `activeRoute` endpoint appears in this + /// instance's routes). Used to attribute the live workspace list to exactly + /// one tag on a multi-tag device. Returns `false` when not connected or the + /// live route is not a host/port endpoint. + private func instanceMatchesActiveRoute(_ instance: RegistryAppInstance) -> Bool { + guard store.connectionState == .connected, + case let .hostPort(liveHost, livePort)? = store.activeRoute?.endpoint else { + return false + } + let normalizedLiveHost = MobileShellRouteAuthPolicy.normalizedManualHost(liveHost) ?? liveHost + return instance.routes.contains { route in + guard case let .hostPort(host, port) = route.endpoint else { return false } + let normalizedHost = MobileShellRouteAuthPolicy.normalizedManualHost(host) ?? host + return normalizedHost == normalizedLiveHost && port == livePort + } + } + + private func deviceExpansionID(_ device: RegistryDevice) -> String { + "device:\(device.deviceId)" + } + + private func instanceExpansionID(device: RegistryDevice, instance: RegistryAppInstance) -> String { + "instance:\(device.deviceId):\(instance.tag)" + } + + private func setExpanded(_ id: String, _ expanded: Bool) { + var store = expansion + store.setExpanded(id, expanded) + expandedStorage = store.storage + } +} + +/// The immutable connect payload for one instance, captured out of the +/// `@Observable` store so the row's action closure never holds a store reference. +private struct DeviceTreeInstanceCapture { + let deviceId: String + let displayName: String? + let tag: String + let routes: [CmxAttachRoute] + + var hasReachableRoute: Bool { !routes.isEmpty } +} +#endif diff --git a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index bdc8d2dea3f7..3fb243abca31 100644 --- a/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -44,6 +44,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). @@ -105,6 +106,11 @@ struct WorkspaceListView: View { ToolbarItem(placement: .topBarLeading) { settingsMenu } + if store != nil { + ToolbarItem(placement: .topBarLeading) { + devicesButton + } + } ToolbarItem(placement: .topBarTrailing) { newWorkspaceButton } @@ -127,9 +133,30 @@ struct WorkspaceListView: View { 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") 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 46d85b5a8474..5efeabccfeec 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -817,6 +817,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": { @@ -970,6 +1191,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": { @@ -1463,6 +1718,23 @@ } } }, + "mobile.settings.devices": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Devices" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイス" + } + } + } + }, "mobile.settings.display": { "extractionState": "manual", "localizations": { @@ -3401,874 +3673,840 @@ } } }, - "terminal.input_accessory.showKeyboard": { + "terminal.input_accessory.paste": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Keyboard" + "value": "Paste" } }, "ja": { "stringUnit": { "state": "translated", - "value": "キーボードを表示" + "value": "ペースト" } } } }, - "terminal.input_accessory.title.alt": { + "mobile.pairing.accountMismatch": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Alt" + "value": "This Mac is signed in to a different cmux account. Sign out and sign back in with the account that owns this Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Alt" + "value": "このMacは別のcmuxアカウントでサインインしています。サインアウトして、このMacを所有するアカウントでサインインし直してください。" } } } }, - "terminal.input_accessory.title.clear": { + "mobile.pairing.appNotRunning": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear" + "value": "Your Mac is reachable, but cmux isn't running there (or mobile pairing is off). Open cmux on the Mac, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリア" + "value": "Macには接続できますが、cmuxが起動していません(またはモバイルのペア設定がオフです)。Macでcmuxを開いてから、もう一度お試しください。" } } } }, - "terminal.input_accessory.title.control": { + "mobile.pairing.connectTimedOutFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ctrl" + "value": "No response from %@:%d. Your Mac may be asleep or off Tailscale. Make sure it's awake and on the same Tailscale network." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl" - } - } - } - }, - "terminal.input_accessory.title.end": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "End" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "End" - } - } - } - }, - "terminal.input_accessory.title.escape": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Esc" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "Esc" + "value": "%@:%d から応答がありません。Macがスリープしているか、Tailscaleに接続されていない可能性があります。Macがスリープしておらず、同じTailscaleネットワーク上にあることを確認してください。" } } } }, - "terminal.input_accessory.title.home": { + "mobile.pairing.dnsFailedFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "Couldn't resolve %@. Check that Tailscale is connected on both devices." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Home" + "value": "%@ を解決できませんでした。両方の端末でTailscaleが接続されていることを確認してください。" } } } }, - "terminal.input_accessory.title.tab": { + "mobile.pairing.fail.offline": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "This device looks offline. Connect to Wi-Fi or cellular, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tab" + "value": "この端末はオフラインのようです。Wi-Fiまたはモバイルデータに接続してから、もう一度お試しください。" } } } }, - "terminal.input_accessory.zoom_in": { + "mobile.pairing.guidance.localNetwork": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom In" + "value": "Settings > cmux > Local Network, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "拡大" + "value": "設定 > cmux > ローカルネットワーク を開いてから、もう一度お試しください。" } } } }, - "terminal.input_accessory.zoom_out": { + "mobile.pairing.guidance.openMacApp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Zoom Out" + "value": "Open cmux on your Mac, then scan the QR or link from it again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "縮小" + "value": "Macでcmuxを開いてから、そのQRコードまたはリンクをもう一度スキャンしてください。" } } } }, - "terminal.shortcut.name.ctrlL": { + "mobile.pairing.guidance.reachability": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Clear (Control-L)" + "value": "Check that this phone and your Mac are on the same Wi-Fi or both running Tailscale, that the Mac is awake, and that cmux is open on it." } }, "ja": { "stringUnit": { "state": "translated", - "value": "クリア (Control-L)" + "value": "このスマートフォンとMacが同じWi-Fi上にあるか、両方でTailscaleが動作していること、Macがスリープしていないこと、Macでcmuxが開いていることを確認してください。" } } } }, - "terminal.shortcut.name.control": { + "mobile.pairing.guidance.rescanFresh": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "Open the pairing window on your Mac and scan a fresh QR or link." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Control" + "value": "Macでペアリングウィンドウを開き、新しいQRコードまたはリンクをスキャンしてください。" } } } }, - "terminal.shortcut.name.alternate": { + "mobile.pairing.guidance.sameAccount": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Option" + "value": "Both devices must be signed in to the same cmux account." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Option" + "value": "両方の端末が同じcmuxアカウントでサインインしている必要があります。" } } } }, - "terminal.shortcut.name.command": { + "mobile.pairing.hostUnreachableFormat": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "Can't reach %@:%d. Make sure your Mac is awake and on the same Tailscale network as this device." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "%@:%d に接続できません。Macがスリープしておらず、この端末と同じTailscaleネットワーク上にあることを確認してください。" } } } }, - "terminal.zoom.current_size": { + "mobile.pairing.localNetworkPermission": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%lld pt" + "value": "iOS blocked the connection. Allow cmux to use the Local Network in iOS Settings, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%lld pt" + "value": "iOSが接続をブロックしました。iOSの設定でcmuxにローカルネットワークの使用を許可してから、もう一度お試しください。" } } } }, - "terminal.zoom.reset_to_default": { + "mobile.onboarding.skip": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reset to default" + "value": "Skip" } }, "ja": { "stringUnit": { "state": "translated", - "value": "デフォルトに戻す" + "value": "スキップ" } } } }, - "terminal.zoom.restore_built_in": { + "mobile.onboarding.next": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Restore built-in" + "value": "Next" } }, "ja": { "stringUnit": { "state": "translated", - "value": "標準に戻す" + "value": "次へ" } } } }, - "terminal.zoom.set_as_default": { + "mobile.onboarding.getStarted": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set as default" + "value": "Get started" } }, "ja": { "stringUnit": { "state": "translated", - "value": "デフォルトに設定" + "value": "始める" } } } }, - "mobile.notifications.enable": { + "mobile.onboarding.whatTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Notify Me About Agents" + "value": "Your Mac's terminals, on your phone" } }, "ja": { "stringUnit": { "state": "translated", - "value": "エージェント通知を受け取る" + "value": "Macのターミナルを、iPhoneで" } } } }, - "mobile.notifications.disable": { + "mobile.onboarding.whatBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Turn Off Agent Notifications" + "value": "cmux runs your terminals and AI coding agents on your Mac. This app lets you watch them, type, and get notified when an agent needs you, right from your phone." } }, "ja": { "stringUnit": { "state": "translated", - "value": "エージェント通知をオフにする" + "value": "cmuxはMac上でターミナルとAIコーディングエージェントを動かします。このアプリを使えば、iPhoneからその様子を見たり、入力したり、エージェントから呼ばれたときに通知を受け取れます。" } } } }, - "terminal.input_accessory.paste": { + "mobile.onboarding.connectTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paste" + "value": "A private link to your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペースト" + "value": "Macへのプライベートな接続" } } } }, - "mobile.pairing.accountMismatch": { + "mobile.onboarding.connectBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This Mac is signed in to a different cmux account. Sign out and sign back in with the account that owns this Mac." + "value": "Your phone has to reach your Mac over the network. The simplest private path is Tailscale: both devices join the same tailnet and connect directly, with no cloud relay. On the same Wi-Fi you can instead type the Mac's local address by hand, but that link is unencrypted, so Tailscale is recommended." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このMacは別のcmuxアカウントでサインインしています。サインアウトして、このMacを所有するアカウントでサインインし直してください。" + "value": "iPhoneはネットワーク経由でMacに届く必要があります。最もシンプルでプライベートな方法はTailscaleです。両方の端末を同じtailnetに参加させると、クラウド中継なしで直接つながります。同じWi-Fiなら、Macのローカルアドレスを手入力して接続することもできますが、その接続は暗号化されないため、Tailscaleをおすすめします。" } } } }, - "mobile.pairing.appNotRunning": { + "mobile.onboarding.tailscaleLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your Mac is reachable, but cmux isn't running there (or mobile pairing is off). Open cmux on the Mac, then try again." + "value": "Set up Tailscale on the Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macには接続できますが、cmuxが起動していません(またはモバイルのペア設定がオフです)。Macでcmuxを開いてから、もう一度お試しください。" + "value": "MacにTailscaleを設定" } } } }, - "mobile.pairing.connectTimedOutFormat": { + "mobile.onboarding.pairTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No response from %@:%d. Your Mac may be asleep or off Tailscale. Make sure it's awake and on the same Tailscale network." + "value": "Pair your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@:%d から応答がありません。Macがスリープしているか、Tailscaleに接続されていない可能性があります。Macがスリープしておらず、同じTailscaleネットワーク上にあることを確認してください。" + "value": "Macとペアリング" } } } }, - "mobile.pairing.dnsFailedFormat": { + "mobile.onboarding.pairBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't resolve %@. Check that Tailscale is connected on both devices." + "value": "Make sure cmux on your Mac is signed in to the same account, then scan the pairing QR code it shows (or enter its address by hand). You only do this once per Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ を解決できませんでした。両方の端末でTailscaleが接続されていることを確認してください。" + "value": "Mac版cmuxが同じアカウントでサインインしていることを確認し、表示されるペアリングQRコードを読み取ってください(またはアドレスを手入力します)。これはMacごとに一度だけ行います。" } } } }, - "mobile.pairing.fail.offline": { + "mobile.settings.howPairingWorks": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This device looks offline. Connect to Wi-Fi or cellular, then try again." + "value": "How Pairing Works" } }, "ja": { "stringUnit": { "state": "translated", - "value": "この端末はオフラインのようです。Wi-Fiまたはモバイルデータに接続してから、もう一度お試しください。" + "value": "ペアリングの仕組み" } } } }, - "mobile.pairing.guidance.localNetwork": { + "mobile.onboarding.tailscaleTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Settings > cmux > Local Network, then try again." + "value": "Put both devices on Tailscale" } }, "ja": { "stringUnit": { "state": "translated", - "value": "設定 > cmux > ローカルネットワーク を開いてから、もう一度お試しください。" + "value": "両方の端末をTailscaleに入れる" } } } }, - "mobile.pairing.guidance.openMacApp": { + "mobile.onboarding.tailscaleBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open cmux on your Mac, then scan the QR or link from it again." + "value": "Tailscale is a free app that gives your devices a private network. Do this once, on both the phone and the Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macでcmuxを開いてから、そのQRコードまたはリンクをもう一度スキャンしてください。" + "value": "Tailscaleは、端末同士をプライベートなネットワークでつなぐ無料アプリです。iPhoneとMacの両方で一度だけ設定してください。" } } } }, - "mobile.pairing.guidance.reachability": { + "mobile.onboarding.tailscaleStep1": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check that this phone and your Mac are on the same Wi-Fi or both running Tailscale, that the Mac is awake, and that cmux is open on it." + "value": "Install Tailscale on this phone and on your Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このスマートフォンとMacが同じWi-Fi上にあるか、両方でTailscaleが動作していること、Macがスリープしていないこと、Macでcmuxが開いていることを確認してください。" + "value": "この端末とMacの両方にTailscaleをインストールします。" } } } }, - "mobile.pairing.guidance.rescanFresh": { + "mobile.onboarding.tailscaleStep2": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open the pairing window on your Mac and scan a fresh QR or link." + "value": "Sign both in to the same Tailscale account so they share one tailnet." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macでペアリングウィンドウを開き、新しいQRコードまたはリンクをスキャンしてください。" + "value": "両方を同じTailscaleアカウントでサインインし、同じtailnetを共有させます。" } } } }, - "mobile.pairing.guidance.sameAccount": { + "mobile.onboarding.tailscaleStep3": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Both devices must be signed in to the same cmux account." + "value": "Keep both signed in to the same cmux account too. Pairing checks that they match." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方の端末が同じcmuxアカウントでサインインしている必要があります。" + "value": "両方を同じcmuxアカウントでもサインインしておきます。ペアリング時に一致を確認します。" } } } }, - "mobile.pairing.hostUnreachableFormat": { + "mobile.onboarding.tailscaleAppStoreLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Can't reach %@:%d. Make sure your Mac is awake and on the same Tailscale network as this device." + "value": "Get Tailscale for iPhone" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@:%d に接続できません。Macがスリープしておらず、この端末と同じTailscaleネットワーク上にあることを確認してください。" + "value": "iPhone版Tailscaleを入手" } } } }, - "mobile.pairing.localNetworkPermission": { + "mobile.onboarding.troubleConnecting": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "iOS blocked the connection. Allow cmux to use the Local Network in iOS Settings, then try again." + "value": "Trouble connecting?" } }, "ja": { "stringUnit": { "state": "translated", - "value": "iOSが接続をブロックしました。iOSの設定でcmuxにローカルネットワークの使用を許可してから、もう一度お試しください。" + "value": "接続できないときは" } } } }, - "mobile.onboarding.skip": { + "mobile.setupHelp.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Skip" + "value": "Set up your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "スキップ" + "value": "Macをセットアップ" } } } }, - "mobile.onboarding.next": { + "mobile.setupHelp.intro": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Next" + "value": "To see your Mac's terminals here, four things have to line up. The step you are on is marked below." } }, "ja": { "stringUnit": { "state": "translated", - "value": "次へ" + "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。今いるステップは下に印が付いています。" } } } }, - "mobile.onboarding.getStarted": { + "mobile.setupHelp.youAreHere": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get started" + "value": "You are here" } }, "ja": { "stringUnit": { "state": "translated", - "value": "始める" + "value": "ここです" } } } }, - "mobile.onboarding.whatTitle": { + "mobile.setupHelp.signInTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your Mac's terminals, on your phone" + "value": "Sign in" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macのターミナルを、iPhoneで" + "value": "サインイン" } } } }, - "mobile.onboarding.whatBody": { + "mobile.setupHelp.signInBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux runs your terminals and AI coding agents on your Mac. This app lets you watch them, type, and get notified when an agent needs you, right from your phone." + "value": "Sign in to cmux on this phone with the same account your Mac uses. Without that, there is nothing to connect to." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmuxはMac上でターミナルとAIコーディングエージェントを動かします。このアプリを使えば、iPhoneからその様子を見たり、入力したり、エージェントから呼ばれたときに通知を受け取れます。" + "value": "この端末で、Macと同じアカウントを使ってcmuxにサインインします。サインインしないと、接続先がありません。" } } } }, - "mobile.onboarding.connectTitle": { + "mobile.setupHelp.macAppTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "A private link to your Mac" + "value": "Run cmux on your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macへのプライベートな接続" + "value": "Macでcmuxを起動" } } } }, - "mobile.onboarding.connectBody": { + "mobile.setupHelp.macAppBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Your phone has to reach your Mac over the network. The simplest private path is Tailscale: both devices join the same tailnet and connect directly, with no cloud relay. On the same Wi-Fi you can instead type the Mac's local address by hand, but that link is unencrypted, so Tailscale is recommended." + "value": "Install the cmux Mac app and leave it running, signed in to the same account. The phone pairs to a running Mac, so a quit or never-installed app is the most common reason pairing does nothing." } }, "ja": { "stringUnit": { "state": "translated", - "value": "iPhoneはネットワーク経由でMacに届く必要があります。最もシンプルでプライベートな方法はTailscaleです。両方の端末を同じtailnetに参加させると、クラウド中継なしで直接つながります。同じWi-Fiなら、Macのローカルアドレスを手入力して接続することもできますが、その接続は暗号化されないため、Tailscaleをおすすめします。" + "value": "cmuxのMacアプリをインストールし、同じアカウントでサインインしたまま起動しておきます。iPhoneは起動中のMacとペアリングするため、アプリが終了している、または未インストールであることが、ペアリングが進まない最も多い原因です。" } } } }, - "mobile.onboarding.tailscaleLink": { + "mobile.setupHelp.macAppLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up Tailscale on the Mac" + "value": "Download cmux for Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacにTailscaleを設定" + "value": "Mac版cmuxをダウンロード" } } } }, - "mobile.onboarding.pairTitle": { + "mobile.setupHelp.unreachableTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair your Mac" + "value": "Wake the Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macとペアリング" + "value": "Macを起こす" } } } }, - "mobile.onboarding.pairBody": { + "mobile.setupHelp.unreachableBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Make sure cmux on your Mac is signed in to the same account, then scan the pairing QR code it shows (or enter its address by hand). You only do this once per Mac." + "value": "You have paired this Mac before but it is not reachable now. Wake it, make sure cmux is running, and confirm both devices are on the same tailnet or Wi-Fi. Then reconnect." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Mac版cmuxが同じアカウントでサインインしていることを確認し、表示されるペアリングQRコードを読み取ってください(またはアドレスを手入力します)。これはMacごとに一度だけ行います。" + "value": "このMacは以前ペアリング済みですが、今は届きません。Macを起こし、cmuxが起動していることを確認し、両方の端末が同じtailnetまたはWi-Fiにあることを確かめてから再接続してください。" } } } }, - "mobile.settings.howPairingWorks": { + "mobile.setupHelp.mismatchTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "How Pairing Works" + "value": "Match the account" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングの仕組み" + "value": "アカウントを合わせる" } } } }, - "mobile.onboarding.tailscaleTitle": { + "mobile.setupHelp.mismatchBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Put both devices on Tailscale" + "value": "If the Mac rejects this device's sign-in, the two are on different cmux accounts or this device's session is stale. Sign this phone in to the Mac's account (or sign the Mac in to this one), then pair again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方の端末をTailscaleに入れる" + "value": "Macがこの端末のサインインを拒否する場合、両者が別々のcmuxアカウントになっているか、この端末のセッションが古くなっています。この端末をMacのアカウントでサインインし(またはMacをこの端末のアカウントでサインインし)、もう一度ペアリングしてください。" } } } }, - "mobile.onboarding.tailscaleBody": { + "mobile.setupHelp.networkTitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tailscale is a free app that gives your devices a private network. Do this once, on both the phone and the Mac." + "value": "Get them on the same network" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tailscaleは、端末同士をプライベートなネットワークでつなぐ無料アプリです。iPhoneとMacの両方で一度だけ設定してください。" + "value": "同じネットワークに入れる" } } } }, - "mobile.onboarding.tailscaleStep1": { + "mobile.setupHelp.networkBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install Tailscale on this phone and on your Mac." + "value": "Scanning the Mac's QR code needs Tailscale: the Mac only shows a code when it has a Tailscale address your phone can reach. Install Tailscale on both, sign both in to the same tailnet, and the code appears." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この端末とMacの両方にTailscaleをインストールします。" + "value": "MacのQRコードをスキャンするにはTailscaleが必要です。Macは、iPhoneから届くTailscaleアドレスを持っているときだけコードを表示します。両方にTailscaleをインストールし、同じtailnetでサインインすると、コードが表示されます。" } } } }, - "mobile.onboarding.tailscaleStep2": { + "mobile.setupHelp.tailscaleAppStore": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign both in to the same Tailscale account so they share one tailnet." + "value": "Get Tailscale for iPhone" } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方を同じTailscaleアカウントでサインインし、同じtailnetを共有させます。" + "value": "iPhone版Tailscaleを入手" } } } }, - "mobile.onboarding.tailscaleStep3": { + "mobile.setupHelp.tailscaleMac": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Keep both signed in to the same cmux account too. Pairing checks that they match." + "value": "Set up Tailscale on the Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方を同じcmuxアカウントでもサインインしておきます。ペアリング時に一致を確認します。" + "value": "MacにTailscaleを設定" } } } }, - "mobile.onboarding.tailscaleAppStoreLink": { + "mobile.setupHelp.lanBody": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get Tailscale for iPhone" + "value": "No Tailscale? On the same Wi-Fi you can still connect by typing the Mac's local address and port by hand in Add device. That link is unencrypted, so only use it on a network you trust." } }, "ja": { "stringUnit": { "state": "translated", - "value": "iPhone版Tailscaleを入手" + "value": "Tailscaleがない場合でも、同じWi-Fiなら、「デバイスを追加」でMacのローカルアドレスとポートを手入力して接続できます。その接続は暗号化されないため、信頼できるネットワークでのみ使用してください。" } } } }, - "mobile.onboarding.troubleConnecting": { + "mobile.setupHelp.sameAccountFooter": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Trouble connecting?" + "value": "The Mac and this phone must be signed in to the same cmux account, and on the same tailnet (or the same Wi-Fi for a manual local connection)." } }, "ja": { "stringUnit": { "state": "translated", - "value": "接続できないときは" + "value": "Macとこの端末は、同じcmuxアカウントでサインインし、同じtailnet(手動でローカル接続する場合は同じWi-Fi)にある必要があります。" } } } }, - "mobile.setupHelp.title": { + "mobile.settings.setUpYourMac": { "extractionState": "manual", "localizations": { "en": { @@ -4285,342 +4523,342 @@ } } }, - "mobile.setupHelp.intro": { + "mobile.devices.setupHelp": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "To see your Mac's terminals here, four things have to line up. The step you are on is marked below." + "value": "Trouble connecting?" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。今いるステップは下に印が付いています。" + "value": "接続できないときは" } } } }, - "mobile.setupHelp.youAreHere": { + "mobile.setupHelp.introReference": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You are here" + "value": "To see your Mac's terminals here, four things have to line up." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここです" + "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。" } } } }, - "mobile.setupHelp.signInTitle": { + "terminal.input_accessory.showKeyboard": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in" + "value": "Show Keyboard" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サインイン" + "value": "キーボードを表示" } } } }, - "mobile.setupHelp.signInBody": { + "terminal.input_accessory.title.alt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in to cmux on this phone with the same account your Mac uses. Without that, there is nothing to connect to." + "value": "Alt" } }, "ja": { "stringUnit": { "state": "translated", - "value": "この端末で、Macと同じアカウントを使ってcmuxにサインインします。サインインしないと、接続先がありません。" + "value": "Alt" } } } }, - "mobile.setupHelp.macAppTitle": { + "terminal.input_accessory.title.clear": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Run cmux on your Mac" + "value": "Clear" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macでcmuxを起動" + "value": "クリア" } } } }, - "mobile.setupHelp.macAppBody": { + "terminal.input_accessory.title.control": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Install the cmux Mac app and leave it running, signed in to the same account. The phone pairs to a running Mac, so a quit or never-installed app is the most common reason pairing does nothing." + "value": "Ctrl" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmuxのMacアプリをインストールし、同じアカウントでサインインしたまま起動しておきます。iPhoneは起動中のMacとペアリングするため、アプリが終了している、または未インストールであることが、ペアリングが進まない最も多い原因です。" + "value": "Ctrl" } } } }, - "mobile.setupHelp.macAppLink": { + "terminal.input_accessory.title.end": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Download cmux for Mac" + "value": "End" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Mac版cmuxをダウンロード" + "value": "End" } } } }, - "mobile.setupHelp.unreachableTitle": { + "terminal.input_accessory.title.escape": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Wake the Mac" + "value": "Esc" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macを起こす" + "value": "Esc" } } } }, - "mobile.setupHelp.unreachableBody": { + "terminal.input_accessory.title.home": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "You have paired this Mac before but it is not reachable now. Wake it, make sure cmux is running, and confirm both devices are on the same tailnet or Wi-Fi. Then reconnect." + "value": "Home" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このMacは以前ペアリング済みですが、今は届きません。Macを起こし、cmuxが起動していることを確認し、両方の端末が同じtailnetまたはWi-Fiにあることを確かめてから再接続してください。" + "value": "Home" } } } }, - "mobile.setupHelp.mismatchTitle": { + "terminal.input_accessory.title.tab": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Match the account" + "value": "Tab" } }, "ja": { "stringUnit": { "state": "translated", - "value": "アカウントを合わせる" + "value": "Tab" } } } }, - "mobile.setupHelp.mismatchBody": { + "terminal.input_accessory.zoom_in": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "If the Mac rejects this device's sign-in, the two are on different cmux accounts or this device's session is stale. Sign this phone in to the Mac's account (or sign the Mac in to this one), then pair again." + "value": "Zoom In" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macがこの端末のサインインを拒否する場合、両者が別々のcmuxアカウントになっているか、この端末のセッションが古くなっています。この端末をMacのアカウントでサインインし(またはMacをこの端末のアカウントでサインインし)、もう一度ペアリングしてください。" + "value": "拡大" } } } }, - "mobile.setupHelp.networkTitle": { + "terminal.input_accessory.zoom_out": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get them on the same network" + "value": "Zoom Out" } }, "ja": { "stringUnit": { "state": "translated", - "value": "同じネットワークに入れる" + "value": "縮小" } } } }, - "mobile.setupHelp.networkBody": { + "terminal.shortcut.name.alternate": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Scanning the Mac's QR code needs Tailscale: the Mac only shows a code when it has a Tailscale address your phone can reach. Install Tailscale on both, sign both in to the same tailnet, and the code appears." + "value": "Option" } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacのQRコードをスキャンするにはTailscaleが必要です。Macは、iPhoneから届くTailscaleアドレスを持っているときだけコードを表示します。両方にTailscaleをインストールし、同じtailnetでサインインすると、コードが表示されます。" + "value": "Option" } } } }, - "mobile.setupHelp.tailscaleAppStore": { + "terminal.shortcut.name.command": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Get Tailscale for iPhone" + "value": "Command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "iPhone版Tailscaleを入手" + "value": "Command" } } } }, - "mobile.setupHelp.tailscaleMac": { + "terminal.shortcut.name.control": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up Tailscale on the Mac" + "value": "Control" } }, "ja": { "stringUnit": { "state": "translated", - "value": "MacにTailscaleを設定" + "value": "Control" } } } }, - "mobile.setupHelp.lanBody": { + "terminal.shortcut.name.ctrlL": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No Tailscale? On the same Wi-Fi you can still connect by typing the Mac's local address and port by hand in Add device. That link is unencrypted, so only use it on a network you trust." + "value": "Clear (Control-L)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Tailscaleがない場合でも、同じWi-Fiなら、「デバイスを追加」でMacのローカルアドレスとポートを手入力して接続できます。その接続は暗号化されないため、信頼できるネットワークでのみ使用してください。" + "value": "クリア (Control-L)" } } } }, - "mobile.setupHelp.sameAccountFooter": { + "terminal.zoom.current_size": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The Mac and this phone must be signed in to the same cmux account, and on the same tailnet (or the same Wi-Fi for a manual local connection)." + "value": "%lld pt" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macとこの端末は、同じcmuxアカウントでサインインし、同じtailnet(手動でローカル接続する場合は同じWi-Fi)にある必要があります。" + "value": "%lld pt" } } } }, - "mobile.settings.setUpYourMac": { + "terminal.zoom.reset_to_default": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Set up your Mac" + "value": "Reset to default" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macをセットアップ" + "value": "デフォルトに戻す" } } } }, - "mobile.devices.setupHelp": { + "terminal.zoom.restore_built_in": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Trouble connecting?" + "value": "Restore built-in" } }, "ja": { "stringUnit": { "state": "translated", - "value": "接続できないときは" + "value": "標準に戻す" } } } }, - "mobile.setupHelp.introReference": { + "terminal.zoom.set_as_default": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "To see your Mac's terminals here, four things have to line up." + "value": "Set as default" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ここでMacのターミナルを表示するには、4つの条件がそろう必要があります。" + "value": "デフォルトに設定" } } }