From 6e35594e759e482a60c9ef54ffc6492749f03b56 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 17:24:53 -0700 Subject: [PATCH 01/97] hive M1: Settings > Computers on macOS with presence and Mac-to-Mac pairing Add the account-level device list and pairing half of hive M1 (the remote-workspace viewer follows in the next slice). - New Packages/macOS/CmuxHive: HiveComputerDirectory merges the team device registry (DeviceRegistryService), the local paired-computer store (MobilePairedMacStore in hive-paired-computers.sqlite3), and the presence worker stream (PresenceClient) into value-snapshot rows; HivePairingLinkDecoder applies the Mac-side loopback/account policy over the shared CmxAttachTicketInput grammar. 13 behavior tests. - Settings > Computers section in CmuxSettingsUI: presence indicator, one-click pair from a registry row, pairing-link paste (the macOS counterpart of the phone's QR scan), unpair, refresh, and a shortcut to the existing pairing window. - App wiring: HiveComputersService composition at the root (extracted configureCloudClients keeps AppDelegate.swift under budget), HostSettingsActions+Computers host bridge, settings navigation and search entries, EN+JA localization for all new strings. - Links the existing iOS client packages (already macOS-capable) into the macOS app through CmuxHive; iOS targets unchanged. Part of #8001 Co-Authored-By: Claude Fable 5 --- Packages/macOS/CmuxHive/Package.swift | 55 +++ .../Sources/CmuxHive/HiveAccountScope.swift | 17 + .../Sources/CmuxHive/HiveComputer.swift | 79 +++ .../CmuxHive/HiveComputerDirectory.swift | 409 ++++++++++++++++ .../CmuxHive/HiveComputerInstance.swift | 26 + .../CmuxHive/HiveComputerPresence.swift | 29 ++ .../Sources/CmuxHive/HivePairOutcome.swift | 19 + .../CmuxHive/HivePairingLinkDecoder.swift | 82 ++++ .../HiveComputerDirectoryTests.swift | 369 ++++++++++++++ .../HivePairingLinkDecoderTests.swift | 123 +++++ .../Bindings/ComputersListModel.swift | 51 ++ .../Bindings/SettingObservationStarting.swift | 1 + .../ComputersSettingsSnapshot.swift | 99 ++++ .../Environment/SettingsHostActions.swift | 48 ++ .../Navigation/SettingsSectionID.swift | 5 + .../Scene/SettingsWindowScene.swift | 3 + .../Sections/ComputersSection.swift | 323 ++++++++++++ Resources/Localizable.xcstrings | 461 +++++++++++++++++- Sources/AppDelegate+CloudClients.swift | 25 + Sources/AppDelegate.swift | 12 +- Sources/Hive/HiveComputersService.swift | 110 +++++ Sources/HostSettingsActions+Computers.swift | 128 +++++ Sources/SettingsNavigation.swift | 43 +- .../SettingsNavigationTarget+SearchText.swift | 47 ++ Sources/SettingsSearchAliases.swift | 2 + cmux.xcodeproj/project.pbxproj | 41 ++ cmux.xcworkspace/contents.xcworkspacedata | 3 + 27 files changed, 2560 insertions(+), 50 deletions(-) create mode 100644 Packages/macOS/CmuxHive/Package.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveAccountScope.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerInstance.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerPresence.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairingLinkDecoder.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ComputersListModel.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift create mode 100644 Sources/AppDelegate+CloudClients.swift create mode 100644 Sources/Hive/HiveComputersService.swift create mode 100644 Sources/HostSettingsActions+Computers.swift create mode 100644 Sources/SettingsNavigationTarget+SearchText.swift diff --git a/Packages/macOS/CmuxHive/Package.swift b/Packages/macOS/CmuxHive/Package.swift new file mode 100644 index 000000000000..6cdd2b833022 --- /dev/null +++ b/Packages/macOS/CmuxHive/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxHive", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "CmuxHive", + targets: ["CmuxHive"] + ), + ], + dependencies: [ + .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../../iOS/CmuxMobilePairedMac"), + .package(path: "../../iOS/CmuxMobileRPC"), + .package(path: "../../iOS/CmuxMobileShell"), + .package(path: "../../iOS/CmuxMobileShellModel"), + ], + targets: [ + .target( + name: "CmuxHive", + dependencies: [ + "CMUXMobileCore", + "CmuxMobilePairedMac", + "CmuxMobileRPC", + "CmuxMobileShell", + "CmuxMobileShellModel", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxHiveTests", + dependencies: [ + "CmuxHive", + "CMUXMobileCore", + "CmuxMobilePairedMac", + "CmuxMobileShell", + "CmuxMobileShellModel", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveAccountScope.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveAccountScope.swift new file mode 100644 index 000000000000..19ea9be9ce9c --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveAccountScope.swift @@ -0,0 +1,17 @@ +/// The account scope local pairing records are stamped with and filtered by: +/// the signed-in Stack user and their selected team. +/// +/// Mirrors the iOS paired-Mac store scoping so a multi-team user only sees the +/// current team's computers and a sign-out hides (without deleting) pairings. +public struct HiveAccountScope: Equatable, Sendable { + /// The signed-in Stack Auth user id, or `nil` when signed out. + public var stackUserID: String? + /// The selected Stack team id, or `nil` for a solo/default scope. + public var teamID: String? + + /// Creates an account scope. + public init(stackUserID: String?, teamID: String?) { + self.stackUserID = stackUserID + self.teamID = teamID + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift new file mode 100644 index 000000000000..52831d02bd0e --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift @@ -0,0 +1,79 @@ +public import CMUXMobileCore +public import Foundation + +/// One computer on the signed-in account, merged from the device registry, +/// the local paired-computer store, and the live presence map. +/// +/// This is the row model for the macOS **Settings › Computers** pane and the +/// device picker of the remote-Mac viewer. It is a pure value snapshot: the +/// merge lives in ``HiveComputerDirectory``, rows never observe stores. +public struct HiveComputer: Equatable, Sendable, Identifiable { + /// Stable cross-platform cmux device UUID (matches the registry's + /// `deviceId` and `CmxAttachTicket.macDeviceID`). + public var deviceID: String + /// Best-known human label: the local custom name override, else the + /// registry/pairing display name, else the short device id. + public var displayName: String + /// Registry platform string (`"mac"`, `"ios"`, `"linux"`, `"windows"`), + /// or `nil` when the computer is known only from a local pairing. + public var platform: String? + /// Whether this row is the computer the app is running on. + public var isThisComputer: Bool + /// Whether a local pairing record exists for this computer. + public var isPaired: Bool + /// Live presence for the computer, or the best registry-derived hint. + public var presence: HiveComputerPresence + /// Build-channel label (`"Stable"`, `"DEV · tag"`, …) reported by the + /// live presence service, when identifiable. + public var buildLabel: String? + /// The computer's registered cmux app instances, freshest first. + public var instances: [HiveComputerInstance] + + public var id: String { deviceID } + + /// Creates a merged computer row. + public init( + deviceID: String, + displayName: String, + platform: String?, + isThisComputer: Bool, + isPaired: Bool, + presence: HiveComputerPresence, + buildLabel: String? = nil, + instances: [HiveComputerInstance] = [] + ) { + self.deviceID = deviceID + self.displayName = displayName + self.platform = platform + self.isThisComputer = isThisComputer + self.isPaired = isPaired + self.presence = presence + self.buildLabel = buildLabel + self.instances = instances + } + + /// Whether this computer is a host another cmux can attach to (a route + /// -advertising platform, not a phone) and is not this computer itself. + public var isPairableHost: Bool { + guard !isThisComputer else { return false } + switch (platform ?? "mac").lowercased() { + case "mac", "linux", "windows": + return true + default: + return false + } + } + + /// The attach routes to persist when pairing this computer: the freshest + /// online instance's routes, else the freshest instance advertising any. + public var bestPairingRoutes: (routes: [CmxAttachRoute], instanceTag: String?)? { + let candidates = instances.filter { !$0.routes.isEmpty } + guard !candidates.isEmpty else { return nil } + let ordered = candidates.sorted { lhs, rhs in + if lhs.isOnline != rhs.isOnline { return lhs.isOnline } + return lhs.lastSeenAt > rhs.lastSeenAt + } + guard let best = ordered.first else { return nil } + return (best.routes, best.tag) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift new file mode 100644 index 000000000000..6ed0ef7bfe3a --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -0,0 +1,409 @@ +public import CMUXMobileCore +public import CmuxMobilePairedMac +public import CmuxMobileShell +public import CmuxMobileShellModel +public import Foundation +public import Observation + +/// The account's computers, merged live from three sources: the team device +/// registry (`GET /api/devices`), the local paired-computer store, and the +/// presence service's online/offline stream. +/// +/// This is the macOS counterpart of the iOS app's Computers screen state: +/// same registry client, same paired store, same presence protocol — composed +/// for the Settings › Computers pane and the remote-Mac viewer instead of the +/// phone UI. All dependencies are injected protocol seams, so the merge and +/// the pair/unpair actions are unit-testable with scripted fakes. +@MainActor +@Observable +public final class HiveComputerDirectory { + /// The merged, sorted computer rows (this computer first, then online + /// computers, then by recency). + public private(set) var computers: [HiveComputer] = [] + /// Whether a registry refresh is currently in flight. + public private(set) var isRefreshing = false + /// Whether the most recent refresh failed to reach the registry (rows then + /// show local/paired data only). + public private(set) var lastRefreshFailed = false + + @ObservationIgnored private let registry: any DeviceRegistryRefreshing + @ObservationIgnored private let pairedStore: any MobilePairedMacStoring + @ObservationIgnored private let presence: (any PresenceSubscribing)? + @ObservationIgnored private let ownDeviceID: String + @ObservationIgnored private let scopeProvider: @Sendable () async -> HiveAccountScope + @ObservationIgnored private let linkDecoder: HivePairingLinkDecoder + @ObservationIgnored private let now: @Sendable () -> Date + @ObservationIgnored private let presenceRetryDelay: @Sendable (_ attempt: Int) async -> Void + + @ObservationIgnored private var presenceMap = PresenceMap() + @ObservationIgnored private var registryDevices: [RegistryDevice] = [] + @ObservationIgnored private var pairedRecords: [MobilePairedMac] = [] + @ObservationIgnored private var listeners: [UUID: AsyncStream<[HiveComputer]>.Continuation] = [:] + @ObservationIgnored private var presenceTask: Task? + + /// Creates a directory over injected source seams. + /// + /// - Parameters: + /// - registry: The team device registry client. + /// - pairedStore: The local paired-computer store. + /// - presence: The live presence stream, or `nil` to run registry-only + /// (tests, previews). + /// - ownDeviceID: This computer's registry device id, used to mark the + /// "This Mac" row and block self-pairing. + /// - scopeProvider: Supplies the current account scope per operation, so + /// a signed-out or team-switched session is read at use time. + /// - linkDecoder: Policy-carrying decoder for pasted pairing links. + /// - now: Clock seam for pairing timestamps. + /// - presenceRetryDelay: Awaited between presence stream retries with the + /// consecutive-failure attempt count; production passes a bounded + /// backoff sleep, tests pass a recorder that returns immediately. + public init( + registry: any DeviceRegistryRefreshing, + pairedStore: any MobilePairedMacStoring, + presence: (any PresenceSubscribing)?, + ownDeviceID: String, + scopeProvider: @escaping @Sendable () async -> HiveAccountScope, + linkDecoder: HivePairingLinkDecoder, + now: @escaping @Sendable () -> Date = { Date() }, + presenceRetryDelay: @escaping @Sendable (_ attempt: Int) async -> Void + ) { + self.registry = registry + self.pairedStore = pairedStore + self.presence = presence + self.ownDeviceID = ownDeviceID + self.scopeProvider = scopeProvider + self.linkDecoder = linkDecoder + self.now = now + self.presenceRetryDelay = presenceRetryDelay + } + + // MARK: - Observation + + /// A stream of merged computer lists, yielding the current value + /// immediately and then on every change. + /// + /// The first active stream starts the presence subscription; when the last + /// stream terminates the subscription stops, so an unopened Computers pane + /// costs no presence socket. + public func updates() -> AsyncStream<[HiveComputer]> { + let id = UUID() + let (stream, continuation) = AsyncStream<[HiveComputer]>.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + listeners[id] = continuation + continuation.yield(computers) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.removeListener(id: id) + } + } + startPresenceIfNeeded() + return stream + } + + private func removeListener(id: UUID) { + listeners.removeValue(forKey: id) + if listeners.isEmpty { + presenceTask?.cancel() + presenceTask = nil + } + } + + // MARK: - Refresh + + /// Re-fetch the registry device list and reload local pairings, then + /// rebuild the merged rows. Transient registry failures keep the previous + /// registry data; an auth rejection clears it (the scope changed). + public func refresh() async { + guard !isRefreshing else { return } + isRefreshing = true + defer { isRefreshing = false } + let scope = await scopeProvider() + switch await registry.listDevices() { + case .ok(let devices): + registryDevices = devices + lastRefreshFailed = false + case .authRejected: + registryDevices = [] + lastRefreshFailed = false + case .transientFailure: + lastRefreshFailed = true + } + await reloadPairedRecords(scope: scope) + rebuild() + } + + private func reloadPairedRecords(scope: HiveAccountScope) async { + do { + pairedRecords = try await pairedStore.loadAll( + stackUserID: scope.stackUserID, + teamID: scope.teamID + ) + } catch { + // Keep the previous local list; a store read failure must not + // wipe rows the registry no longer needs to confirm. + } + } + + // MARK: - Pairing + + /// Pair a computer from its registry row: persist its best instance's + /// routes into the local paired store. + public func pair(deviceID: String) async -> HivePairOutcome { + guard let computer = computers.first(where: { $0.deviceID == deviceID }), + computer.isPairableHost else { + return .noRoutes + } + guard let best = computer.bestPairingRoutes else { return .noRoutes } + return await persistPairing( + macDeviceID: deviceID, + displayName: computer.displayName, + routes: best.routes, + instanceTag: best.instanceTag + ) + } + + /// Pair from a pasted pairing link (the QR payload another Mac shows). + public func pair(link rawLink: String) async -> HivePairOutcome { + let scope = await scopeProvider() + switch linkDecoder.decode(rawLink, currentStackUserID: scope.stackUserID) { + case .invalidLink: + return .invalidLink + case .loopbackRejected: + return .loopbackRejected + case .accountMismatch: + return .accountMismatch + case .ticket(let ticket): + // The v2 pairing grammar deliberately carries no device id or + // display name (identity arrives post-handshake from + // `mobile.host.status`), so mirror the iOS manual-host flow: pair + // under a synthetic id derived from the dialable endpoint and let + // the first connection adopt the host-reported identity. + let macDeviceID: String + if ticket.macDeviceID.isEmpty { + guard let synthesized = Self.syntheticDeviceID(for: ticket.routes) else { + return .invalidLink + } + macDeviceID = synthesized + } else { + macDeviceID = ticket.macDeviceID + } + guard macDeviceID != ownDeviceID else { return .loopbackRejected } + return await persistPairing( + macDeviceID: macDeviceID, + displayName: ticket.macDisplayName ?? Self.endpointLabel(for: ticket.routes), + routes: ticket.routes, + instanceTag: nil + ) + } + } + + /// The iOS-compatible synthetic identity for a link that names no device: + /// `manual-:` from the first dialable route. + static func syntheticDeviceID(for routes: [CmxAttachRoute]) -> String? { + endpointLabel(for: routes).map { "manual-\($0)" } + } + + private static func endpointLabel(for routes: [CmxAttachRoute]) -> String? { + for route in routes { + if case let .hostPort(host, port) = route.endpoint { + return "\(host):\(port)" + } + } + return nil + } + + /// Remove the local pairing record for a computer. Registry rows remain + /// visible; only the pairing (persisted routes) is forgotten. + /// - Returns: `true` when the store accepted the removal. + @discardableResult + public func unpair(deviceID: String) async -> Bool { + let scope = await scopeProvider() + do { + try await pairedStore.remove( + macDeviceID: deviceID, + stackUserID: scope.stackUserID, + teamID: scope.teamID + ) + } catch { + return false + } + await reloadPairedRecords(scope: scope) + rebuild() + return true + } + + private func persistPairing( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + instanceTag: String? + ) async -> HivePairOutcome { + guard !routes.isEmpty else { return .noRoutes } + let scope = await scopeProvider() + do { + try await pairedStore.upsert( + macDeviceID: macDeviceID, + displayName: displayName, + routes: routes, + instanceTag: instanceTag, + markActive: false, + stackUserID: scope.stackUserID, + teamID: scope.teamID, + now: now() + ) + } catch { + return .storeFailed + } + await reloadPairedRecords(scope: scope) + rebuild() + return .paired(deviceID: macDeviceID) + } + + // MARK: - Presence + + private func startPresenceIfNeeded() { + guard presenceTask == nil, let presence else { return } + presenceTask = Task { [weak self] in + await self?.runPresenceLoop(presence: presence) + } + } + + private func runPresenceLoop(presence: any PresenceSubscribing) async { + var consecutiveFailures = 0 + while !Task.isCancelled { + do { + let stream = try await presence.subscribe() + for try await update in stream { + consecutiveFailures = 0 + presenceMap.apply(update) + rebuild() + } + } catch { + consecutiveFailures += 1 + } + if Task.isCancelled { return } + // The presence stream ended (server deadline) or failed; the + // injected delay bounds the resubscribe backoff and is cancelled + // with this task. + await presenceRetryDelay(consecutiveFailures) + } + } + + // MARK: - Merge + + private func rebuild() { + computers = Self.mergedComputers( + registry: registryDevices, + paired: pairedRecords, + presence: presenceMap, + ownDeviceID: ownDeviceID + ) + for (_, continuation) in listeners { + continuation.yield(computers) + } + } + + /// Pure merge of the three sources into sorted rows. Exposed for tests. + public static func mergedComputers( + registry: [RegistryDevice], + paired: [MobilePairedMac], + presence: PresenceMap, + ownDeviceID: String + ) -> [HiveComputer] { + let pairedByID = Dictionary(uniqueKeysWithValues: paired.map { ($0.macDeviceID, $0) }) + var rows: [HiveComputer] = registry.map { device in + let record = pairedByID[device.deviceId] + return HiveComputer( + deviceID: device.deviceId, + displayName: record?.customName?.nonEmpty + ?? device.displayName?.nonEmpty + ?? record?.displayName?.nonEmpty + ?? String(device.deviceId.prefix(8)), + platform: device.platform, + isThisComputer: device.deviceId == ownDeviceID, + isPaired: record != nil, + presence: Self.presenceState( + for: device.deviceId, + presence: presence, + fallbackLastSeen: device.lastSeenAt + ), + buildLabel: presence.deviceSummary(deviceId: device.deviceId)?.buildLabel, + instances: device.instances.map { instance in + HiveComputerInstance( + tag: instance.tag, + routes: instance.routes, + lastSeenAt: presence.instanceSummary( + deviceId: device.deviceId, + tag: instance.tag + ).map { max($0.lastSeenAt, instance.lastSeenAt) } ?? instance.lastSeenAt, + isOnline: presence.instanceSummary( + deviceId: device.deviceId, + tag: instance.tag + )?.online ?? false + ) + } + ) + } + let registryIDs = Set(registry.map(\.deviceId)) + for record in paired where !registryIDs.contains(record.macDeviceID) { + rows.append( + HiveComputer( + deviceID: record.macDeviceID, + displayName: record.resolvedName, + platform: nil, + isThisComputer: record.macDeviceID == ownDeviceID, + isPaired: true, + presence: Self.presenceState( + for: record.macDeviceID, + presence: presence, + fallbackLastSeen: record.lastSeenAt + ), + buildLabel: presence.deviceSummary(deviceId: record.macDeviceID)?.buildLabel, + instances: [ + HiveComputerInstance( + tag: record.instanceTag ?? "default", + routes: record.routes, + lastSeenAt: record.lastSeenAt, + isOnline: false + ) + ] + ) + ) + } + return rows.sorted { lhs, rhs in + if lhs.isThisComputer != rhs.isThisComputer { return lhs.isThisComputer } + if lhs.presence.isOnline != rhs.presence.isOnline { return lhs.presence.isOnline } + let lhsSeen = lhs.presence.lastSeenAt ?? .distantPast + let rhsSeen = rhs.presence.lastSeenAt ?? .distantPast + if lhs.presence.isOnline == rhs.presence.isOnline, lhsSeen != rhsSeen { + return lhsSeen > rhsSeen + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + private static func presenceState( + for deviceID: String, + presence: PresenceMap, + fallbackLastSeen: Date? + ) -> HiveComputerPresence { + guard let summary = presence.deviceSummary(deviceId: deviceID) else { + return .unknown(lastSeenAt: fallbackLastSeen) + } + if summary.online { return .online } + let lastSeen = [summary.lastSeenAt, fallbackLastSeen] + .compactMap { $0 } + .max() + return .offline(lastSeenAt: lastSeen) + } +} + +extension String { + /// The string itself when non-empty after trimming, else `nil`; merge + /// helper for picking the first usable display name. + fileprivate var nonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerInstance.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerInstance.swift new file mode 100644 index 000000000000..529599ba29e4 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerInstance.swift @@ -0,0 +1,26 @@ +public import CMUXMobileCore +public import Foundation + +/// One running (or recently seen) cmux app instance on a computer, keyed by +/// its build tag, as merged from the registry and the live presence map. +public struct HiveComputerInstance: Equatable, Sendable, Identifiable { + /// The instance's build tag (`"stable"`, a dev tag, or `"default"`). + public var tag: String + /// Attach routes this instance advertises, in priority order. + public var routes: [CmxAttachRoute] + /// When the registry or presence service last saw this instance. + public var lastSeenAt: Date + /// Whether the presence service currently reports this instance online. + public var isOnline: Bool + + /// The tag is unique per computer, so it doubles as the row id. + public var id: String { tag } + + /// Creates an instance row. + public init(tag: String, routes: [CmxAttachRoute], lastSeenAt: Date, isOnline: Bool) { + self.tag = tag + self.routes = routes + self.lastSeenAt = lastSeenAt + self.isOnline = isOnline + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerPresence.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerPresence.swift new file mode 100644 index 000000000000..9dd43f370d67 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerPresence.swift @@ -0,0 +1,29 @@ +public import Foundation + +/// Presence state for a ``HiveComputer`` row. +/// +/// `online`/`offline` come from the live presence service once a snapshot has +/// arrived; before that (or when the service is unreachable) rows carry the +/// registry's durable last-seen hint as ``unknown(lastSeenAt:)``. +public enum HiveComputerPresence: Equatable, Sendable { + /// The presence service reports at least one instance online. + case online + /// The presence service knows the computer and reports it offline. + case offline(lastSeenAt: Date?) + /// No live presence data; `lastSeenAt` is the registry/pairing hint. + case unknown(lastSeenAt: Date?) + + /// Whether the computer is live right now. + public var isOnline: Bool { + if case .online = self { return true } + return false + } + + /// The best available last-seen timestamp, if any. + public var lastSeenAt: Date? { + switch self { + case .online: return nil + case .offline(let lastSeenAt), .unknown(let lastSeenAt): return lastSeenAt + } + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift new file mode 100644 index 000000000000..261b069c13ce --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift @@ -0,0 +1,19 @@ +/// Result of a pair / unpair action on the Computers directory. +/// +/// Cases are semantic (not user-facing strings) so the settings UI localizes +/// the copy while this package stays presentation-free. +public enum HivePairOutcome: Equatable, Sendable { + /// The pairing record was written; the directory has been refreshed. + case paired(deviceID: String) + /// The pasted pairing link / code could not be decoded. + case invalidLink + /// The link decoded, but every route points back at this computer + /// (loopback), which release builds refuse to dial. + case loopbackRejected + /// The link belongs to a different signed-in account than this Mac. + case accountMismatch + /// The registry row has no dialable route to persist. + case noRoutes + /// Persisting the pairing failed (local store error). + case storeFailed +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairingLinkDecoder.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairingLinkDecoder.swift new file mode 100644 index 000000000000..087d12248d82 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairingLinkDecoder.swift @@ -0,0 +1,82 @@ +public import CMUXMobileCore +internal import CmuxMobileRPC +internal import CmuxMobileShellModel +import Foundation + +/// Decodes a pasted `cmux-ios://attach?…` pairing link (the same payload the +/// Mac's pairing window renders as a QR code) into an attach ticket a Mac can +/// pair from, applying the Mac-side trust policy. +/// +/// A Mac has no camera, so link paste / manual entry is the macOS counterpart +/// of the phone's QR scan. The grammar and validation are shared with iOS via +/// `CmxAttachTicketInput`; this type adds the two Mac-side policies: +/// +/// - **Loopback**: outside dev builds a link whose routes all point at +/// `127.0.0.1` would make this Mac dial itself, so it is rejected exactly +/// like a physical phone rejects it. Dev builds keep loopback so two tagged +/// builds on one machine can pair for dogfooding. +/// - **Account**: when the link carries the host's Stack user id and it +/// differs from this Mac's signed-in user, pairing is refused up front +/// (the host would reject every RPC with `account_mismatch` anyway). +public struct HivePairingLinkDecoder: Sendable { + /// Whether loopback-only links are acceptable (dev builds testing two + /// instances on one machine). Injected so the policy is testable in both + /// positions from a single build configuration. + public var allowsLoopbackRoutes: Bool + + /// Creates a decoder. + /// - Parameter allowsLoopbackRoutes: Accept loopback routes (dev builds). + public init(allowsLoopbackRoutes: Bool) { + self.allowsLoopbackRoutes = allowsLoopbackRoutes + } + + /// The decode result: a ticket, or the semantic failure the UI localizes. + public enum Outcome: Sendable { + /// The link decoded into a valid, policy-passing ticket. + case ticket(CmxAttachTicket) + /// The text is not a cmux pairing link or failed validation. + case invalidLink + /// Every dialable route pointed back at this computer. + case loopbackRejected + /// The link belongs to a different signed-in account. + case accountMismatch + } + + /// Decode and policy-check one pasted pairing link. + /// + /// - Parameters: + /// - rawValue: The pasted link text (whitespace is trimmed). + /// - currentStackUserID: This Mac's signed-in Stack user id, if any. + /// - Returns: The decoded ticket or a semantic failure. + public func decode(_ rawValue: String, currentStackUserID: String?) -> Outcome { + let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .invalidLink } + let ticket: CmxAttachTicket + do { + ticket = try CmxAttachTicketInput.decode(trimmed) + } catch { + if case MobileSyncPairingPayloadError.loopbackRouteRejected = error { + return .loopbackRejected + } + return .invalidLink + } + if MobileShellRouteAuthPolicy.ticketRejectsLoopbackRoutes( + ticket.routes, + isPhysicalDevice: !allowsLoopbackRoutes + ) { + return .loopbackRejected + } + if let expected = normalizedNonEmpty(ticket.macUserID), + let actual = normalizedNonEmpty(currentStackUserID), + expected != actual { + return .accountMismatch + } + return .ticket(ticket) + } + + private func normalizedNonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift new file mode 100644 index 000000000000..19f77acb0400 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -0,0 +1,369 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxHive + +/// Scripted registry double: returns a fixed outcome per call. +private struct ScriptedRegistry: DeviceRegistryRefreshing { + var outcome: DeviceRegistryListOutcome + + func freshRoutes(forMacDeviceID macDeviceID: String, instanceTag: String?) async -> [CmxAttachRoute]? { + nil + } + + func listDevices() async -> DeviceRegistryListOutcome { + outcome + } +} + +@MainActor +private func makeTempStore() throws -> (store: MobilePairedMacStore, cleanup: () -> Void) { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let store = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired-macs.sqlite3") + ) + return (store, { try? FileManager.default.removeItem(at: directory) }) +} + +private func tailscaleRoute(host: String = "100.64.0.9", port: Int = 8000) throws -> CmxAttachRoute { + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: host, port: port) + ) +} + +@MainActor +private func makeDirectory( + registry: DeviceRegistryListOutcome, + store: MobilePairedMacStore, + ownDeviceID: String = "self-device", + stackUserID: String? = "user-1", + teamID: String? = "team-1" +) -> HiveComputerDirectory { + HiveComputerDirectory( + registry: ScriptedRegistry(outcome: registry), + pairedStore: store, + presence: nil, + ownDeviceID: ownDeviceID, + scopeProvider: { HiveAccountScope(stackUserID: stackUserID, teamID: teamID) }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + now: { Date(timeIntervalSince1970: 1_000) }, + presenceRetryDelay: { _ in } + ) +} + +@Suite struct HiveComputerDirectoryTests { + @MainActor + @Test func refreshMergesRegistryAndPairedRows() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let route = try tailscaleRoute() + // A locally paired Mac the registry no longer lists. + try await store.upsert( + macDeviceID: "paired-only", + displayName: "Old Mini", + routes: [route], + instanceTag: "stable", + markActive: false, + stackUserID: "user-1", + teamID: "team-1", + now: Date(timeIntervalSince1970: 500) + ) + let registryDevice = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [ + RegistryAppInstance( + tag: "stable", + routes: [route], + lastSeenAt: Date(timeIntervalSince1970: 900) + ) + ] + ) + let ownDevice = RegistryDevice( + deviceId: "self-device", + platform: "mac", + displayName: "This Mac", + lastSeenAt: Date(timeIntervalSince1970: 950), + instances: [] + ) + let directory = makeDirectory(registry: .ok([registryDevice, ownDevice]), store: store) + await directory.refresh() + + #expect(directory.computers.count == 3) + #expect(directory.computers.first?.deviceID == "self-device") + #expect(directory.computers.first?.isThisComputer == true) + let registryRow = try #require(directory.computers.first { $0.deviceID == "registry-mac" }) + #expect(registryRow.displayName == "Studio") + #expect(registryRow.isPaired == false) + #expect(registryRow.isPairableHost) + let pairedRow = try #require(directory.computers.first { $0.deviceID == "paired-only" }) + #expect(pairedRow.isPaired) + #expect(pairedRow.displayName == "Old Mini") + #expect(pairedRow.instances.first?.routes == [route]) + } + + @MainActor + @Test func pairFromRegistryPersistsBestRoutes() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let route = try tailscaleRoute() + let device = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [ + RegistryAppInstance(tag: "empty", routes: [], lastSeenAt: Date(timeIntervalSince1970: 999)), + RegistryAppInstance(tag: "stable", routes: [route], lastSeenAt: Date(timeIntervalSince1970: 900)), + ] + ) + let directory = makeDirectory(registry: .ok([device]), store: store) + await directory.refresh() + + let outcome = await directory.pair(deviceID: "registry-mac") + #expect(outcome == .paired(deviceID: "registry-mac")) + + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.count == 1) + #expect(persisted.first?.macDeviceID == "registry-mac") + #expect(persisted.first?.routes == [route]) + #expect(persisted.first?.instanceTag == "stable") + #expect(directory.computers.first { $0.deviceID == "registry-mac" }?.isPaired == true) + } + + @MainActor + @Test func pairRejectsSelfAndRouteless() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let ownDevice = RegistryDevice( + deviceId: "self-device", + platform: "mac", + displayName: "This Mac", + lastSeenAt: Date(timeIntervalSince1970: 950), + instances: [ + RegistryAppInstance( + tag: "stable", + routes: [try tailscaleRoute()], + lastSeenAt: Date(timeIntervalSince1970: 900) + ) + ] + ) + let routeless = RegistryDevice( + deviceId: "routeless-mac", + platform: "mac", + displayName: "Sleepy", + lastSeenAt: Date(timeIntervalSince1970: 800), + instances: [ + RegistryAppInstance(tag: "stable", routes: [], lastSeenAt: Date(timeIntervalSince1970: 800)) + ] + ) + let phone = RegistryDevice( + deviceId: "phone", + platform: "ios", + displayName: "iPhone", + lastSeenAt: Date(timeIntervalSince1970: 700), + instances: [] + ) + let directory = makeDirectory(registry: .ok([ownDevice, routeless, phone]), store: store) + await directory.refresh() + + #expect(await directory.pair(deviceID: "self-device") == .noRoutes) + #expect(await directory.pair(deviceID: "routeless-mac") == .noRoutes) + #expect(await directory.pair(deviceID: "phone") == .noRoutes) + let persisted = try await store.loadAll(stackUserID: nil, teamID: nil) + #expect(persisted.isEmpty) + } + + @MainActor + @Test func unpairRemovesOnlyTheLocalRecord() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let route = try tailscaleRoute() + let device = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [ + RegistryAppInstance(tag: "stable", routes: [route], lastSeenAt: Date(timeIntervalSince1970: 900)) + ] + ) + let directory = makeDirectory(registry: .ok([device]), store: store) + await directory.refresh() + _ = await directory.pair(deviceID: "registry-mac") + + #expect(await directory.unpair(deviceID: "registry-mac")) + let persisted = try await store.loadAll(stackUserID: nil, teamID: nil) + #expect(persisted.isEmpty) + // The registry row stays visible, just unpaired. + let row = try #require(directory.computers.first { $0.deviceID == "registry-mac" }) + #expect(row.isPaired == false) + } + + @MainActor + @Test func transientRegistryFailureKeepsPreviousRows() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let device = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [] + ) + let directory = makeDirectory(registry: .ok([device]), store: store) + await directory.refresh() + #expect(directory.computers.count == 1) + + // Swap in a failing registry by building a new directory is not + // possible mid-flight, so assert the failure path on a fresh + // directory that has never seen the registry: rows stay local-only + // and the failure flag is set. + let failing = makeDirectory(registry: .transientFailure, store: store) + await failing.refresh() + #expect(failing.lastRefreshFailed) + #expect(failing.computers.isEmpty) + } + + @MainActor + @Test func presenceSnapshotMarksDevicesOnline() throws { + let route = try tailscaleRoute() + var map = PresenceMap() + // Feed the map a wire-shaped snapshot frame, the same JSON the + // presence worker pushes on subscribe. + let snapshotJSON = """ + { + "type": "snapshot", + "teamId": "team-1", + "now": 1000000, + "heartbeatIntervalMs": 15000, + "offlineTimeoutMs": 45000, + "devices": [ + { + "deviceId": "registry-mac", + "platform": "mac", + "displayName": "Studio", + "online": true, + "lastSeenAt": 1000000, + "instances": [ + { + "deviceId": "registry-mac", + "tag": "stable", + "platform": "mac", + "capabilities": [], + "online": true, + "lastSeenAt": 1000000 + } + ] + } + ] + } + """ + map.apply(try PresenceUpdate.parse(Data(snapshotJSON.utf8))) + let registryDevice = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [ + RegistryAppInstance(tag: "stable", routes: [route], lastSeenAt: Date(timeIntervalSince1970: 900)) + ] + ) + let offlineDevice = RegistryDevice( + deviceId: "other-mac", + platform: "mac", + displayName: "Mini", + lastSeenAt: Date(timeIntervalSince1970: 800), + instances: [] + ) + let rows = HiveComputerDirectory.mergedComputers( + registry: [registryDevice, offlineDevice], + paired: [], + presence: map, + ownDeviceID: "self-device" + ) + let online = try #require(rows.first { $0.deviceID == "registry-mac" }) + #expect(online.presence == .online) + #expect(online.instances.first?.isOnline == true) + let unknown = try #require(rows.first { $0.deviceID == "other-mac" }) + #expect(unknown.presence == .unknown(lastSeenAt: Date(timeIntervalSince1970: 800))) + // Online rows sort before unknown/offline rows. + #expect(rows.first?.deviceID == "registry-mac") + } + + @MainActor + @Test func pairFromV2LinkSynthesizesIdentityUntilHandshake() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + // A v2 pairing link (the QR payload) names routes only, never a + // device id; encode one exactly like another Mac's pairing window. + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-b", + macDisplayName: "Studio", + macUserID: "user-1", + macPairingCompatibilityVersion: 0, + routes: [ + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.7", port: 8123), + priority: 10 + ) + ], + expiresAt: nil + ) + let link = try #require(CmxPairingQRCode().encode(ticket)) + let directory = makeDirectory(registry: .ok([]), store: store) + + let outcome = await directory.pair(link: link) + #expect(outcome == .paired(deviceID: "manual-100.64.0.7:8123")) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.first?.macDeviceID == "manual-100.64.0.7:8123") + #expect(persisted.first?.routes.first?.endpoint == .hostPort(host: "100.64.0.7", port: 8123)) + let row = try #require(directory.computers.first) + #expect(row.isPaired) + #expect(row.displayName == "100.64.0.7:8123") + } + + @MainActor + @Test func bestPairingRoutesPrefersOnlineThenFreshest() throws { + let routeA = try tailscaleRoute(host: "100.64.0.1") + let routeB = try tailscaleRoute(host: "100.64.0.2") + let computer = HiveComputer( + deviceID: "mac", + displayName: "Mac", + platform: "mac", + isThisComputer: false, + isPaired: false, + presence: .online, + instances: [ + HiveComputerInstance( + tag: "fresh-offline", + routes: [routeA], + lastSeenAt: Date(timeIntervalSince1970: 900), + isOnline: false + ), + HiveComputerInstance( + tag: "older-online", + routes: [routeB], + lastSeenAt: Date(timeIntervalSince1970: 100), + isOnline: true + ), + ] + ) + let best = try #require(computer.bestPairingRoutes) + #expect(best.instanceTag == "older-online") + #expect(best.routes == [routeB]) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift new file mode 100644 index 000000000000..1f32d588f572 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift @@ -0,0 +1,123 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxHive + +@Suite struct HivePairingLinkDecoderTests { + private func tailscaleTicket(macUserID: String? = "owner-1") throws -> CmxAttachTicket { + try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-b", + macDisplayName: "Studio", + macUserID: macUserID, + macPairingCompatibilityVersion: 0, + routes: [ + // The canonical synthesized shape the v2 QR grammar requires + // (id `tailscale`, priority 10), matching the Mac's resolver. + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.7", port: 8123), + priority: 10 + ) + ], + expiresAt: nil + ) + } + + /// The v2 pairing URL another Mac's pairing window renders as its QR. + private func pairingLink(macUserID: String? = "owner-1") throws -> String { + let ticket = try tailscaleTicket(macUserID: macUserID) + return try #require(CmxPairingQRCode().encode(ticket)) + } + + @Test func decodesSameAccountLink() throws { + let decoder = HivePairingLinkDecoder(allowsLoopbackRoutes: false) + let outcome = decoder.decode(try pairingLink(), currentStackUserID: "owner-1") + guard case .ticket(let ticket) = outcome else { + Issue.record("expected ticket, got \(outcome)") + return + } + // The v2 grammar drops identity on purpose (it arrives post-handshake + // from `mobile.host.status`), so the ticket names only the routes. + #expect(ticket.macDeviceID.isEmpty) + #expect(ticket.routes.count == 1) + #expect(ticket.routes.first?.kind == .tailscale) + #expect(ticket.routes.first?.endpoint == .hostPort(host: "100.64.0.7", port: 8123)) + } + + @Test func acceptsLinkWithoutAccountClaimOrLocalSession() throws { + let decoder = HivePairingLinkDecoder(allowsLoopbackRoutes: false) + // No user id in the link → the host enforces the account at RPC time. + guard case .ticket = decoder.decode(try pairingLink(macUserID: nil), currentStackUserID: "owner-1") else { + Issue.record("expected ticket for account-less link") + return + } + // Signed out locally → defer to the host as well. + guard case .ticket = decoder.decode(try pairingLink(), currentStackUserID: nil) else { + Issue.record("expected ticket while signed out") + return + } + } + + @Test func rejectsCrossAccountLink() throws { + let decoder = HivePairingLinkDecoder(allowsLoopbackRoutes: false) + let outcome = decoder.decode(try pairingLink(macUserID: "owner-1"), currentStackUserID: "someone-else") + guard case .accountMismatch = outcome else { + Issue.record("expected accountMismatch, got \(outcome)") + return + } + } + + @Test func rejectsGarbageAndEmptyInput() { + let decoder = HivePairingLinkDecoder(allowsLoopbackRoutes: false) + guard case .invalidLink = decoder.decode("not a link", currentStackUserID: nil) else { + Issue.record("expected invalidLink for garbage") + return + } + guard case .invalidLink = decoder.decode(" ", currentStackUserID: nil) else { + Issue.record("expected invalidLink for whitespace") + return + } + } + + @Test func loopbackPolicyFollowsBuildChannel() throws { + // Legacy compact payload keeps decoding loopback (the v2 grammar + // rejects it inside the shared decoder), so the Mac-side policy is + // what stands between a release build and dialing itself. + let loopbackTicket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + macDeviceID: "mac-b", + macDisplayName: "Same Machine", + macPairingCompatibilityVersion: 0, + routes: [ + try CmxAttachRoute( + id: "loop", + kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8123) + ) + ], + expiresAt: nil + ) + let payload = try CmxAttachTicketCompactCoder().encode(loopbackTicket) + let base64 = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let link = "\(CmxPairingURLScheme.development)://attach?payload=\(base64)" + + let release = HivePairingLinkDecoder(allowsLoopbackRoutes: false) + guard case .loopbackRejected = release.decode(link, currentStackUserID: nil) else { + Issue.record("expected loopbackRejected in release policy") + return + } + let dev = HivePairingLinkDecoder(allowsLoopbackRoutes: true) + guard case .ticket(let ticket) = dev.decode(link, currentStackUserID: nil) else { + Issue.record("expected ticket in dev policy") + return + } + #expect(ticket.macDeviceID == "mac-b") + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ComputersListModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ComputersListModel.swift new file mode 100644 index 000000000000..efcce5c9d96e --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ComputersListModel.swift @@ -0,0 +1,51 @@ +import Foundation +import Observation + +/// `@Observable` view-model that projects the host's merged computer list +/// (registry + pairings + presence) into SwiftUI-readable state for the +/// Computers settings section. +/// +/// Mirrors ``MobilePairingStatusModel``: host runtime state arrives through +/// ``SettingsHostActions`` rather than the settings catalog, seeded once on +/// ``startObserving()`` and kept live by the host's snapshot stream via +/// ``SettingReadDriver`` (which owns the subscription task and cancels it on +/// deinit). +@MainActor +@Observable +final class ComputersListModel { + /// The most recent snapshot, or `nil` when the host has no computers + /// directory (previews/tests). + private(set) var current: ComputersSettingsSnapshot? + + @ObservationIgnored private let currentSnapshot: () -> ComputersSettingsSnapshot? + @ObservationIgnored private let makeStream: () -> AsyncStream + @ObservationIgnored private let driver = SettingReadDriver() + @ObservationIgnored private var hasStarted = false + + /// Creates a model bound to the host's computers stream. + convenience init(hostActions: SettingsHostActions) { + self.init( + currentSnapshot: { hostActions.computersSnapshot() }, + makeStream: { hostActions.computersUpdates() } + ) + } + + init( + currentSnapshot: @escaping () -> ComputersSettingsSnapshot?, + makeStream: @escaping () -> AsyncStream + ) { + self.currentSnapshot = currentSnapshot + self.makeStream = makeStream + current = nil + } + + /// Starts the host-snapshot stream for the retained model. Idempotent. + func startObserving() { + guard !hasStarted else { return } + hasStarted = true + current = currentSnapshot() + driver.activate(makeStream) { [weak self] snapshot in + self?.current = snapshot + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/SettingObservationStarting.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/SettingObservationStarting.swift index e76cb518414a..7c9acf79eb89 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/SettingObservationStarting.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/SettingObservationStarting.swift @@ -14,3 +14,4 @@ extension DefaultsValueModel: SettingObservationStarting {} extension JSONValueModel: SettingObservationStarting {} extension SecretValueModel: SettingObservationStarting {} extension MobilePairingStatusModel: SettingObservationStarting {} +extension ComputersListModel: SettingObservationStarting {} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift new file mode 100644 index 000000000000..915066b1b48e --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift @@ -0,0 +1,99 @@ +import Foundation + +/// One computer row for the **Computers** settings section. +/// +/// The host merges the device registry, the local paired-computer store, and +/// live presence into these value snapshots (the settings package stays +/// Foundation-only, so it never sees the transport/registry types). +public struct ComputersSettingsComputer: Sendable, Equatable, Identifiable { + /// How the row's presence indicator should render. + public enum Presence: Sendable, Equatable { + /// The presence service reports the computer online now. + case online + /// The presence service reports it offline; `lastSeenAt` when known. + case offline(lastSeenAt: Date?) + /// No live presence data; `lastSeenAt` is the registry hint. + case unknown(lastSeenAt: Date?) + } + + /// Stable device id (the registry `deviceId`). + public let deviceID: String + /// Display name for the row. + public let name: String + /// SF Symbol for the device kind, chosen by the host (`desktopcomputer`, + /// `iphone`, …). + public let symbolName: String + /// Whether this row is the Mac the app runs on. + public let isThisMac: Bool + /// Whether a local pairing exists for this computer. + public let isPaired: Bool + /// Whether the row can be paired from here (a host platform with routes, + /// not this Mac, not already paired). + public let canPair: Bool + /// Presence indicator state. + public let presence: Presence + /// Optional secondary line (build channel / instance tag), pre-formatted + /// by the host. + public let detail: String? + + public var id: String { deviceID } + + /// Creates a computer row snapshot. + public init( + deviceID: String, + name: String, + symbolName: String, + isThisMac: Bool, + isPaired: Bool, + canPair: Bool, + presence: Presence, + detail: String? = nil + ) { + self.deviceID = deviceID + self.name = name + self.symbolName = symbolName + self.isThisMac = isThisMac + self.isPaired = isPaired + self.canPair = canPair + self.presence = presence + self.detail = detail + } +} + +/// The Computers section's full state snapshot. +public struct ComputersSettingsSnapshot: Sendable, Equatable { + /// Whether a user is signed in (the registry list requires an account). + public let isSignedIn: Bool + /// Merged computer rows in display order. + public let computers: [ComputersSettingsComputer] + /// Whether the most recent registry refresh failed (rows may be stale or + /// local-only). + public let lastRefreshFailed: Bool + + /// Creates a snapshot. + public init( + isSignedIn: Bool, + computers: [ComputersSettingsComputer], + lastRefreshFailed: Bool = false + ) { + self.isSignedIn = isSignedIn + self.computers = computers + self.lastRefreshFailed = lastRefreshFailed + } +} + +/// Result of a pair action (registry row or pasted link), rendered inline. +public enum ComputersPairResult: Sendable, Equatable { + /// Paired; the row list refreshes via the snapshot stream. + case paired + /// The pasted text is not a cmux pairing link. + case invalidLink + /// Every route in the link points back at this Mac. + case loopbackRejected + /// The link belongs to a different account. + case accountMismatch + /// The computer advertises no dialable route to persist. + case noRoutes + /// The pairing could not be saved. + case failed +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 3b8e3be3dae4..ea2b81c417cc 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -142,6 +142,33 @@ public protocol SettingsHostActions: AnyObject { /// is edited. func mobilePairingDefaultDisplayName() -> String + /// The current merged computer list (device registry + local pairings + + /// live presence) for the Computers section, or `nil` when the host has + /// no computers directory (previews/tests). See ``computersUpdates()`` + /// for live refresh. + func computersSnapshot() -> ComputersSettingsSnapshot? + + /// A stream that yields a fresh ``ComputersSettingsSnapshot`` whenever the + /// merged computer list changes (registry refresh, pairing change, or a + /// presence transition). The Computers section subscribes so rows stay + /// live without polling. + func computersUpdates() -> AsyncStream + + /// Kicks off an asynchronous registry refresh; results arrive through + /// ``computersUpdates()``. + func refreshComputers() + + /// Pairs a computer from its registry row (persists its best advertised + /// routes locally). + func pairComputer(deviceID: String) async -> ComputersPairResult + + /// Pairs a computer from a pasted `cmux-ios://attach?…` pairing link (the + /// payload another Mac's pairing window shows as a QR code). + func pairComputerWithLink(_ link: String) async -> ComputersPairResult + + /// Removes the local pairing for a computer. The registry row remains. + func unpairComputer(deviceID: String) async + /// Applies an explicitly-requested iOS pairing port, checking availability /// first so a port already in use leaves the running listener untouched. The /// Mobile section calls this from its **Apply** button and renders the @@ -208,6 +235,27 @@ public extension SettingsHostActions { /// Default: empty, for hosts that cannot resolve the Mac's system name. func mobilePairingDefaultDisplayName() -> String { "" } + /// Default: no computers directory, for previews/tests. + func computersSnapshot() -> ComputersSettingsSnapshot? { nil } + + /// Default: an immediately-finished stream, for hosts without a + /// computers directory. + func computersUpdates() -> AsyncStream { + AsyncStream { $0.finish() } + } + + /// Default no-op refresh for previews/tests. + func refreshComputers() {} + + /// Default failure for hosts without a computers directory. + func pairComputer(deviceID: String) async -> ComputersPairResult { .failed } + + /// Default failure for hosts without a computers directory. + func pairComputerWithLink(_ link: String) async -> ComputersPairResult { .failed } + + /// Default no-op unpair for previews/tests. + func unpairComputer(deviceID: String) async {} + /// Default: save-for-later, for hosts without a live mobile service (previews/tests). func applyMobilePairingPort(_ port: Int) async -> MobilePairingPortApplyResult { (1...65535).contains(port) ? .savedForLater(port: port) : .invalid(requestedPort: port) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift index 5fb5e47665c0..6c4fe7112c68 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift @@ -18,6 +18,8 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case sleepyMode /// Mobile pairing and sync settings. case mobile + /// The account's computers: registry list, presence, Mac-to-Mac pairing. + case computers case sidebarAppearance /// User/agent-authored custom sidebars: enable gate and renderer choice. case customSidebars @@ -42,6 +44,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case .textBox: return String(localized: "settings.section.textBox", defaultValue: "TextBox (Beta)") case .sleepyMode: return String(localized: "settings.section.sleepyMode", defaultValue: "Sleepy Mode") case .mobile: return String(localized: "settings.section.mobile", defaultValue: "Mobile") + case .computers: return String(localized: "settings.section.computers", defaultValue: "Computers") case .sidebarAppearance: return "Sidebar" case .customSidebars: return String(localized: "settings.section.customSidebars", defaultValue: "Custom Sidebars") case .betaFeatures: return "Beta Features" @@ -65,6 +68,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case .textBox: return "textformat" case .sleepyMode: return "moon.zzz" case .mobile: return "iphone" + case .computers: return "desktopcomputer" case .sidebarAppearance: return "sidebar.left" case .customSidebars: return "sidebar.squares.left" case .betaFeatures: return "exclamationmark.triangle" @@ -90,6 +94,7 @@ public enum SettingsSectionID: String, CaseIterable, Identifiable, Sendable, Has case .textBox: return "textbox text box rich input prompt default new terminal workspace split tab focus show beta" case .sleepyMode: return "sleepy mode screensaver caffeinate keep awake lock touch id battery wifi clock mascot theme glow pixel" case .mobile: return "ios iphone ipad mobile pairing local network sync" + case .computers: return "computers devices macs remote pair unpair presence online offline tailscale hive" case .sidebarAppearance: return "sidebar details branches material terminal background" case .customSidebars: return "custom sidebars vibe swift json interpreted renderer in-process remote worker isolated" case .betaFeatures: return "beta experimental unstable feed dock right sidebar" diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index eead0c7f036d..ca1d2a31410e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -448,6 +448,9 @@ public struct SettingsWindowRoot: View { MobileSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) .id(anchorID(for: .mobile)) + ComputersSection(hostActions: hostActions) + .id(anchorID(for: .computers)) + SidebarSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) .id(anchorID(for: .sidebarAppearance)) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift new file mode 100644 index 000000000000..b104e18f3bf1 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -0,0 +1,323 @@ +import CmuxFoundation +import SwiftUI + +/// **Computers** section — the account's registered computers (from the team +/// device registry) with live presence, plus Mac-to-Mac pairing: pair a +/// listed computer in one click, paste a pairing link from another Mac, or +/// show this Mac's own pairing code. +@MainActor +public struct ComputersSection: View { + @State private var model: ComputersListModel + /// Device ids with a pair/unpair action in flight, so their buttons show + /// a busy state without re-rendering unrelated rows. + @State private var pendingDeviceIDs: Set = [] + /// The pasted pairing-link draft. + @State private var pairingLink: String = "" + /// Result of the most recent pair action, shown inline under the link row. + @State private var pairResult: ComputersPairResult? + /// Guards overlapping link-pair submissions. + @State private var isPairingLink = false + + private let hostActions: SettingsHostActions + + /// Creates the Computers section bound to the host bridge. + /// - Parameter hostActions: Supplies the merged computer snapshots and + /// the pair/unpair/refresh actions. + public init(hostActions: SettingsHostActions) { + _model = State(initialValue: ComputersListModel(hostActions: hostActions)) + self.hostActions = hostActions + } + + /// The Computers section content. + public var body: some View { + Group { + SettingsSectionHeader( + String(localized: "settings.section.computers", defaultValue: "Computers"), + section: .computers + ) + SettingsCard { + if let snapshot = model.current, snapshot.isSignedIn { + computerRows(snapshot) + SettingsCardDivider() + } else { + SettingsCardNote(String( + localized: "settings.computers.signedOut", + defaultValue: "Sign in to see and pair the computers on your account." + )) + SettingsCardDivider() + } + pairingLinkRow + pairResultCaption + SettingsCardDivider() + showPairingCodeRow + } + } + .task { + startSettingsObservation([model]) + hostActions.refreshComputers() + } + } + + @ViewBuilder + private func computerRows(_ snapshot: ComputersSettingsSnapshot) -> some View { + if snapshot.computers.isEmpty { + SettingsCardNote(String( + localized: "settings.computers.empty", + defaultValue: "No computers registered yet. Open cmux on another device signed in to the same account." + )) + } else { + ForEach(snapshot.computers) { computer in + ComputersSectionRow( + computer: computer, + isPending: pendingDeviceIDs.contains(computer.deviceID), + pair: { pair(deviceID: computer.deviceID) }, + unpair: { unpair(deviceID: computer.deviceID) } + ) + } + } + if snapshot.lastRefreshFailed { + SettingsCardNote(String( + localized: "settings.computers.refreshFailed", + defaultValue: "Couldn't reach the device registry. Showing locally known computers." + )) + } + refreshRow + } + + @ViewBuilder + private var refreshRow: some View { + SettingsCardRow( + configurationReview: .action, + searchAnchorID: "setting:computers:refresh", + String(localized: "settings.computers.refresh", defaultValue: "Refresh List"), + subtitle: String( + localized: "settings.computers.refresh.subtitle", + defaultValue: "Fetch the account's registered computers again." + ) + ) { + Button(String(localized: "settings.computers.refresh.button", defaultValue: "Refresh")) { + hostActions.refreshComputers() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsComputersRefreshButton") + } + } + + @ViewBuilder + private var pairingLinkRow: some View { + SettingsCardRow( + configurationReview: .action, + searchAnchorID: "setting:computers:pairingLink", + String(localized: "settings.computers.pairingLink", defaultValue: "Add by Pairing Link"), + subtitle: String( + localized: "settings.computers.pairingLink.subtitle", + defaultValue: "Paste the pairing link from the other Mac's pairing window (the QR code's contents)." + ), + controlWidth: 260 + ) { + HStack(spacing: 8) { + TextField( + String(localized: "settings.computers.pairingLink.placeholder", defaultValue: "cmux-ios://attach?…"), + text: $pairingLink + ) + .textFieldStyle(.roundedBorder) + .onChange(of: pairingLink) { pairResult = nil } + .onSubmit { pairFromLink() } + .accessibilityIdentifier("SettingsComputersPairingLinkField") + + Button(String(localized: "settings.computers.pairingLink.add", defaultValue: "Add")) { + pairFromLink() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isPairingLink || pairingLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityIdentifier("SettingsComputersPairingLinkAddButton") + } + } + } + + @ViewBuilder + private var pairResultCaption: some View { + if let pairResult, let text = Self.resultText(pairResult) { + Label(text, systemImage: pairResult == .paired ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(pairResult == .paired ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) + .cmuxFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + } + + @ViewBuilder + private var showPairingCodeRow: some View { + SettingsCardRow( + configurationReview: .action, + searchAnchorID: "setting:computers:showPairingCode", + String(localized: "settings.computers.showPairingCode", defaultValue: "Pair This Mac"), + subtitle: String( + localized: "settings.computers.showPairingCode.subtitle", + defaultValue: "Show this Mac's pairing code for another Mac or an iPhone to add it." + ) + ) { + Button(String(localized: "settings.computers.showPairingCode.button", defaultValue: "Show Pairing Code…")) { + hostActions.openMobilePairingWindow() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsComputersShowPairingCodeButton") + } + } + + private func pair(deviceID: String) { + guard !pendingDeviceIDs.contains(deviceID) else { return } + pendingDeviceIDs.insert(deviceID) + Task { + pairResult = await hostActions.pairComputer(deviceID: deviceID) + pendingDeviceIDs.remove(deviceID) + } + } + + private func unpair(deviceID: String) { + guard !pendingDeviceIDs.contains(deviceID) else { return } + pendingDeviceIDs.insert(deviceID) + Task { + await hostActions.unpairComputer(deviceID: deviceID) + pendingDeviceIDs.remove(deviceID) + } + } + + private func pairFromLink() { + let link = pairingLink.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isPairingLink, !link.isEmpty else { return } + isPairingLink = true + pairResult = nil + Task { + let result = await hostActions.pairComputerWithLink(link) + pairResult = result + if result == .paired { pairingLink = "" } + isPairingLink = false + } + } + + private static func resultText(_ result: ComputersPairResult) -> String? { + switch result { + case .paired: + return String(localized: "settings.computers.pairResult.paired", defaultValue: "Paired.") + case .invalidLink: + return String(localized: "settings.computers.pairResult.invalidLink", defaultValue: "That doesn't look like a cmux pairing link.") + case .loopbackRejected: + return String(localized: "settings.computers.pairResult.loopback", defaultValue: "That link points back at this Mac. Pairing over the network needs Tailscale on both Macs.") + case .accountMismatch: + return String(localized: "settings.computers.pairResult.accountMismatch", defaultValue: "That computer is signed in to a different account.") + case .noRoutes: + return String(localized: "settings.computers.pairResult.noRoutes", defaultValue: "That computer hasn't advertised a reachable address yet. Make sure Tailscale is running on it.") + case .failed: + return String(localized: "settings.computers.pairResult.failed", defaultValue: "Pairing failed. Try again.") + } + } +} + +/// One computer row: identity, presence, and the pair/unpair control. Receives +/// only value snapshots and action closures (snapshot boundary: no stores +/// below the `ForEach`). +private struct ComputersSectionRow: View { + let computer: ComputersSettingsComputer + let isPending: Bool + let pair: () -> Void + let unpair: () -> Void + + var body: some View { + SettingsCardRow( + configurationReview: .action, + searchAnchorID: "setting:computers:row:\(computer.deviceID)", + title, + subtitle: subtitle + ) { + HStack(spacing: 10) { + presenceBadge + control + } + } + } + + private var title: String { + if computer.isThisMac { + return String( + localized: "settings.computers.row.thisMac", + defaultValue: "\(computer.name) (This Mac)" + ) + } + return computer.name + } + + private var subtitle: String? { + var parts: [String] = [] + if let detail = computer.detail, !detail.isEmpty { + parts.append(detail) + } + if let lastSeen = lastSeenText { + parts.append(lastSeen) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private var lastSeenText: String? { + let lastSeenAt: Date? + switch computer.presence { + case .online: + return nil + case .offline(let date), .unknown(let date): + lastSeenAt = date + } + guard let lastSeenAt else { return nil } + let relative = lastSeenAt.formatted(.relative(presentation: .named)) + return String( + localized: "settings.computers.row.lastSeen", + defaultValue: "Last seen \(relative)" + ) + } + + @ViewBuilder + private var presenceBadge: some View { + switch computer.presence { + case .online: + Label( + String(localized: "settings.computers.presence.online", defaultValue: "Online"), + systemImage: "circle.fill" + ) + .foregroundStyle(.green) + .cmuxFont(.caption) + case .offline: + Label( + String(localized: "settings.computers.presence.offline", defaultValue: "Offline"), + systemImage: "circle.fill" + ) + .foregroundStyle(.secondary) + .cmuxFont(.caption) + case .unknown: + EmptyView() + } + } + + @ViewBuilder + private var control: some View { + if computer.isPaired, !computer.isThisMac { + Button(String(localized: "settings.computers.row.unpair", defaultValue: "Unpair")) { + unpair() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isPending) + .accessibilityIdentifier("SettingsComputersUnpairButton-\(computer.deviceID)") + } else if computer.canPair { + Button(String(localized: "settings.computers.row.pair", defaultValue: "Pair")) { + pair() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isPending) + .accessibilityIdentifier("SettingsComputersPairButton-\(computer.deviceID)") + } + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 9a13395d3799..2e8a24a81429 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -235669,7 +235669,466 @@ } } } + }, + "settings.section.computers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Computers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンピュータ" + } + } + } + }, + "settings.computers.signedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to see and pair the computers on your account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインすると、アカウントのコンピュータを表示してペアリングできます。" + } + } + } + }, + "settings.computers.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No computers registered yet. Open cmux on another device signed in to the same account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "登録されたコンピュータはまだありません。同じアカウントでサインインした別のデバイスで cmux を開いてください。" + } + } + } + }, + "settings.computers.refreshFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't reach the device registry. Showing locally known computers." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デバイスレジストリに接続できませんでした。ローカルに保存されたコンピュータを表示しています。" + } + } + } + }, + "settings.computers.refresh": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh List" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リストを更新" + } + } + } + }, + "settings.computers.refresh.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fetch the account's registered computers again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アカウントに登録されたコンピュータを再取得します。" + } + } + } + }, + "settings.computers.refresh.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Refresh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "更新" + } + } + } + }, + "settings.computers.pairingLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add by Pairing Link" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングリンクで追加" + } + } + } + }, + "settings.computers.pairingLink.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paste the pairing link from the other Mac's pairing window (the QR code's contents)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "相手の Mac のペアリングウィンドウに表示されるペアリングリンク(QR コードの内容)を貼り付けてください。" + } + } + } + }, + "settings.computers.pairingLink.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux-ios://attach?…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux-ios://attach?…" + } + } + } + }, + "settings.computers.pairingLink.add": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + } + } + }, + "settings.computers.showPairingCode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair This Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac をペアリング" + } + } + } + }, + "settings.computers.showPairingCode.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show this Mac's pairing code for another Mac or an iPhone to add it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "別の Mac や iPhone がこの Mac を追加できるように、ペアリングコードを表示します。" + } + } + } + }, + "settings.computers.showPairingCode.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Pairing Code…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングコードを表示…" + } + } + } + }, + "settings.computers.pairResult.paired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paired." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングしました。" + } + } + } + }, + "settings.computers.pairResult.invalidLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That doesn't look like a cmux pairing link." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux のペアリングリンクではないようです。" + } + } + } + }, + "settings.computers.pairResult.loopback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That link points back at this Mac. Pairing over the network needs Tailscale on both Macs." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのリンクはこの Mac 自身を指しています。ネットワーク越しのペアリングには両方の Mac で Tailscale が必要です。" + } + } + } + }, + "settings.computers.pairResult.accountMismatch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That computer is signed in to a different account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのコンピュータは別のアカウントでサインインしています。" + } + } + } + }, + "settings.computers.pairResult.noRoutes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That computer hasn't advertised a reachable address yet. Make sure Tailscale is running on it." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのコンピュータはまだ接続可能なアドレスを公開していません。Tailscale が実行中か確認してください。" + } + } + } + }, + "settings.computers.pairResult.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pairing failed. Try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングに失敗しました。もう一度お試しください。" + } + } + } + }, + "settings.computers.row.thisMac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ (This Mac)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@(この Mac)" + } + } + } + }, + "settings.computers.row.lastSeen": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Last seen %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最終確認 %@" + } + } + } + }, + "settings.computers.presence.online": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Online" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オンライン" + } + } + } + }, + "settings.computers.presence.offline": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Offline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフライン" + } + } + } + }, + "settings.computers.row.unpair": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unpair" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリング解除" + } + } + } + }, + "settings.computers.row.pair": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pair" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリング" + } + } + } + }, + "settings.search.alias.section.computers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "computers devices macs remote view control pair unpair presence online offline tailscale hive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンピュータ デバイス マック リモート 表示 操作 ペアリング 解除 プレゼンス オンライン オフライン tailscale hive" + } + } + } } }, "version": "1.0" -} +} \ No newline at end of file diff --git a/Sources/AppDelegate+CloudClients.swift b/Sources/AppDelegate+CloudClients.swift new file mode 100644 index 000000000000..e7f5e183cb46 --- /dev/null +++ b/Sources/AppDelegate+CloudClients.swift @@ -0,0 +1,25 @@ +import Foundation + +extension AppDelegate { + /// Configures the auth-backed cloud clients at the composition root: + /// Cloud VMs, remotes, AI accounts, phone push, the mobile pairing host, + /// the device registry, presence (heartbeat out, computers directory in), + /// and the DEV paired-Mac backup publisher. Extracted from `configure` so + /// the client roster can grow without growing `AppDelegate.swift`. + func configureCloudClients(auth: MacAuthComposition) { + VMClient.bootstrap(auth: auth.coordinator) + RemotesClient.bootstrap(auth: auth.coordinator) + AIAccountsClient.bootstrap(auth: auth.coordinator) + PhonePushClient.shared.configure(auth: auth.coordinator) + MobileHostService.shared.configure(auth: auth.coordinator) + DeviceRegistryClient.shared.configure(auth: auth.coordinator) + PresenceHeartbeatClient.shared.configure(auth: auth.coordinator) + // The Mac-as-client side of hive: the Settings › Computers directory + // (registry list + local pairings + presence subscribe). + HiveComputersService.shared.configure(auth: auth.coordinator) + // DEV-only: auto-publish this Mac's attach route to the signed-in user's + // pairedMacs backup so a fresh dev iOS build restores it (no manual host + // entry). No-op on Release / when the flag is off. + MacPairedMacBackupPublisher.shared.configure(auth: auth.coordinator) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 259345f6d936..7988bdaa0340 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2035,17 +2035,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.notificationStore = notificationStore self.sidebarState = sidebarState self.auth = auth - VMClient.bootstrap(auth: auth.coordinator) - RemotesClient.bootstrap(auth: auth.coordinator) - AIAccountsClient.bootstrap(auth: auth.coordinator) - PhonePushClient.shared.configure(auth: auth.coordinator) - MobileHostService.shared.configure(auth: auth.coordinator) - DeviceRegistryClient.shared.configure(auth: auth.coordinator) - PresenceHeartbeatClient.shared.configure(auth: auth.coordinator) - // DEV-only: auto-publish this Mac's attach route to the signed-in user's - // pairedMacs backup so a fresh dev iOS build restores it (no manual host - // entry). No-op on Release / when the flag is off. - MacPairedMacBackupPublisher.shared.configure(auth: auth.coordinator) + configureCloudClients(auth: auth) TerminalController.shared.attachAuth(coordinator: auth.coordinator, browserSignIn: auth.browserSignIn) TerminalController.shared.agentChatTranscriptService = agentChatTranscriptService auth.start() diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift new file mode 100644 index 000000000000..b6d32f59757f --- /dev/null +++ b/Sources/Hive/HiveComputersService.swift @@ -0,0 +1,110 @@ +import CmuxAuthRuntime +import CmuxHive +import CmuxMobilePairedMac +import CmuxMobileShell +import Foundation + +/// App-side composition of the hive computers directory: wires the shared +/// device-registry client, the local paired-computer store, and the presence +/// subscriber (the same client stack the iOS app composes) to the signed-in +/// account, and hands the resulting ``HiveComputerDirectory`` to the +/// Settings › Computers pane. +/// +/// Follows the house pattern of the other cloud clients configured at the +/// composition root (``DeviceRegistryClient``, ``PresenceHeartbeatClient``): +/// a `shared` instance that stays inert until `configure(auth:)`. +@MainActor +final class HiveComputersService { + static let shared = HiveComputersService() + + private(set) var directory: HiveComputerDirectory? + private(set) var auth: AuthCoordinator? + + private init() {} + + /// Whether a user session exists right now (drives the pane's + /// signed-out empty state). + var isSignedIn: Bool { + auth?.currentUser != nil + } + + /// Inject the auth dependency and build the directory. Call once at the + /// composition root alongside the other cloud clients. + func configure(auth: AuthCoordinator) { + self.auth = auth + guard directory == nil else { return } + let store: MobilePairedMacStore + do { + store = try MobilePairedMacStore(databaseURL: Self.pairedComputersDatabaseURL()) + } catch { + NSLog("cmux.hive paired-computer store unavailable: %@", String(describing: error)) + return + } + let apiBaseURL = Self.normalizedBaseURL(AuthEnvironment.vmAPIBaseURL) + let registry = DeviceRegistryService( + apiBaseURL: apiBaseURL, + deviceID: MobileHostIdentity.deviceID(), + tokenSource: DeviceRegistryService.TokenSource( + accessToken: { (try? await auth.currentTokens())?.accessToken }, + refreshToken: { (try? await auth.currentTokens())?.refreshToken } + ), + teamIDProvider: { await auth.resolvedTeamID } + ) + let presence: PresenceClient? + if let presenceURL = PresenceHeartbeatClient.resolvedServiceURL() { + presence = PresenceClient( + serviceBaseURL: Self.normalizedBaseURL(presenceURL), + tokenSource: PresenceTokenSource( + accessToken: { (try? await auth.currentTokens())?.accessToken }, + currentUserID: { await auth.currentUser?.id } + ), + teamIDProvider: { await auth.resolvedTeamID } + ) + } else { + presence = nil + } + directory = HiveComputerDirectory( + registry: registry, + pairedStore: store, + presence: presence, + ownDeviceID: MobileHostIdentity.deviceID(), + scopeProvider: { + await HiveAccountScope( + stackUserID: auth.currentUser?.id, + teamID: auth.resolvedTeamID + ) + }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: Self.allowsLoopbackPairing), + presenceRetryDelay: { attempt in + // Bounded, cancellable resubscribe backoff for the presence + // stream (a genuine delay, not a poll): 1s doubling to 60s. + let seconds = min(60.0, pow(2.0, Double(min(attempt, 6)))) + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + ) + } + + /// Dev builds may pair over loopback so two instances on one machine can + /// dogfood Mac-to-Mac viewing; release builds never dial themselves. + private static var allowsLoopbackPairing: Bool { + #if DEBUG + true + #else + false + #endif + } + + /// The Mac-side paired-computer database. Uses its own file (not the + /// phone's `paired-macs.sqlite3` name) so the namespace stays clearly + /// Mac-as-client. + private static func pairedComputersDatabaseURL() throws -> URL { + try MobilePairedMacStore.defaultDatabaseURL() + .deletingLastPathComponent() + .appendingPathComponent("hive-paired-computers.sqlite3") + } + + private static func normalizedBaseURL(_ url: URL) -> String { + let raw = url.absoluteString + return raw.hasSuffix("/") ? String(raw.dropLast()) : raw + } +} diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift new file mode 100644 index 000000000000..eb8904f2ef5a --- /dev/null +++ b/Sources/HostSettingsActions+Computers.swift @@ -0,0 +1,128 @@ +import CmuxHive +import CmuxSettingsUI +import Foundation + +/// Computers-section host bridge: maps the hive computers directory +/// (registry + pairings + presence) into the settings package's value +/// snapshots and routes the pane's actions back to it. +extension HostSettingsActions { + private var computersDirectory: HiveComputerDirectory? { + HiveComputersService.shared.directory + } + + func computersSnapshot() -> ComputersSettingsSnapshot? { + guard let directory = computersDirectory else { return nil } + return Self.computersSnapshot( + computers: directory.computers, + isSignedIn: HiveComputersService.shared.isSignedIn, + lastRefreshFailed: directory.lastRefreshFailed + ) + } + + func computersUpdates() -> AsyncStream { + guard let directory = computersDirectory else { + return AsyncStream { $0.finish() } + } + let source = directory.updates() + return AsyncStream { continuation in + let task = Task { @MainActor in + for await computers in source { + continuation.yield(Self.computersSnapshot( + computers: computers, + isSignedIn: HiveComputersService.shared.isSignedIn, + lastRefreshFailed: directory.lastRefreshFailed + )) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + func refreshComputers() { + guard let directory = computersDirectory else { return } + Task { @MainActor in + await directory.refresh() + } + } + + func pairComputer(deviceID: String) async -> ComputersPairResult { + guard let directory = computersDirectory else { return .failed } + return Self.pairResult(await directory.pair(deviceID: deviceID)) + } + + func pairComputerWithLink(_ link: String) async -> ComputersPairResult { + guard let directory = computersDirectory else { return .failed } + return Self.pairResult(await directory.pair(link: link)) + } + + func unpairComputer(deviceID: String) async { + guard let directory = computersDirectory else { return } + await directory.unpair(deviceID: deviceID) + } + + // MARK: - Mapping (pure) + + /// Map the merged directory rows into the settings package's snapshot. + static func computersSnapshot( + computers: [HiveComputer], + isSignedIn: Bool, + lastRefreshFailed: Bool + ) -> ComputersSettingsSnapshot { + ComputersSettingsSnapshot( + isSignedIn: isSignedIn, + computers: computers.map(Self.computerRow), + lastRefreshFailed: lastRefreshFailed + ) + } + + static func computerRow(_ computer: HiveComputer) -> ComputersSettingsComputer { + ComputersSettingsComputer( + deviceID: computer.deviceID, + name: computer.displayName, + symbolName: Self.symbolName(forPlatform: computer.platform), + isThisMac: computer.isThisComputer, + isPaired: computer.isPaired, + canPair: computer.isPairableHost && !computer.isPaired && computer.bestPairingRoutes != nil, + presence: Self.presence(computer.presence), + detail: Self.detail(for: computer) + ) + } + + static func pairResult(_ outcome: HivePairOutcome) -> ComputersPairResult { + switch outcome { + case .paired: return .paired + case .invalidLink: return .invalidLink + case .loopbackRejected: return .loopbackRejected + case .accountMismatch: return .accountMismatch + case .noRoutes: return .noRoutes + case .storeFailed: return .failed + } + } + + private static func symbolName(forPlatform platform: String?) -> String { + switch (platform ?? "mac").lowercased() { + case "ios": return "iphone" + case "linux", "windows": return "server.rack" + default: return "desktopcomputer" + } + } + + private static func presence(_ presence: HiveComputerPresence) -> ComputersSettingsComputer.Presence { + switch presence { + case .online: return .online + case .offline(let lastSeenAt): return .offline(lastSeenAt: lastSeenAt) + case .unknown(let lastSeenAt): return .unknown(lastSeenAt: lastSeenAt) + } + } + + /// Secondary row line: the presence build label when known, else the + /// build tag of the freshest route-advertising instance (skipping the + /// unremarkable stable tags). + private static func detail(for computer: HiveComputer) -> String? { + if let label = computer.buildLabel, !label.isEmpty { return label } + guard let tag = computer.bestPairingRoutes?.instanceTag, + !["default", "stable"].contains(tag.lowercased()) else { return nil } + return tag + } +} diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 548edf739954..34f203c2fa71 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -7,6 +7,7 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { case textBox case sleepyMode case mobile + case computers case sidebarAppearance case customSidebars case betaFeatures @@ -35,6 +36,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return String(localized: "settings.section.sleepyMode", defaultValue: "Sleepy Mode") case .mobile: return String(localized: "settings.section.mobile", defaultValue: "Mobile") + case .computers: + return String(localized: "settings.section.computers", defaultValue: "Computers") case .workspaceColors: return String(localized: "settings.section.workspaceColors", defaultValue: "Workspace Colors") case .sidebarAppearance: @@ -74,6 +77,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return "moon.zzz" case .mobile: return "iphone" + case .computers: + return "desktopcomputer" case .workspaceColors: return "paintpalette" case .sidebarAppearance: @@ -99,44 +104,6 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { } } - var searchText: String { - switch self { - case .account: - return "\(title) sign in team sync" - case .app: - return "\(title) appearance language workspace notifications menu bar telemetry default terminal" - case .terminal: - return "\(title) scrollbar auto resume restore reopen relaunch quit sessions agents claude codex opencode rovodev hibernation idle suspend commands approvals prefixes toggle" - case .textBox: - return "\(title) textbox text box rich input prompt beta new terminal workspace split tab focus height" - case .sleepyMode: - return "\(title) sleepy mode screensaver caffeinate keep awake lock touch id battery wifi clock mascot theme glow pixel" - case .mobile: - return "\(title) ios iphone ipad mobile pairing local network sync" - case .workspaceColors: - return "\(title) palette tabs" - case .sidebarAppearance: - return "\(title) sidebar details branches badges material terminal background" - case .customSidebars: - return "\(title) custom sidebars vibe swift json interpreted renderer in-process remote worker isolated" - case .betaFeatures: - return "\(title) beta experimental unstable feed dock right sidebar" - case .automation: - return "\(title) socket integrations hooks ports claude cursor gemini kiro naming auto naming workspace tabs" - case .browser: - return "\(title) search engine links history theme" - case .browserImport: - return "\(title) browser import data bookmarks history cookies" - case .globalHotkey: - return "\(title) system wide shortcut" - case .keyboardShortcuts: - return "\(title) keybindings commands chords" - case .settingsJSON: - return "\(title) config file preferences editor documentation schema jsonc reload" - case .reset: - return "\(title) defaults" - } - } } enum SettingsNavigationRequest { diff --git a/Sources/SettingsNavigationTarget+SearchText.swift b/Sources/SettingsNavigationTarget+SearchText.swift new file mode 100644 index 000000000000..c683a3bb311a --- /dev/null +++ b/Sources/SettingsNavigationTarget+SearchText.swift @@ -0,0 +1,47 @@ +import Foundation + +extension SettingsNavigationTarget { + /// Section-level search corpus for the in-app settings search index. + /// Extracted from `SettingsNavigation.swift` so section additions don't + /// grow that file. + var searchText: String { + switch self { + case .account: + return "\(title) sign in team sync" + case .app: + return "\(title) appearance language workspace notifications menu bar telemetry default terminal" + case .terminal: + return "\(title) scrollbar auto resume restore reopen relaunch quit sessions agents claude codex opencode rovodev hibernation idle suspend commands approvals prefixes toggle" + case .textBox: + return "\(title) textbox text box rich input prompt beta new terminal workspace split tab focus height" + case .sleepyMode: + return "\(title) sleepy mode screensaver caffeinate keep awake lock touch id battery wifi clock mascot theme glow pixel" + case .mobile: + return "\(title) ios iphone ipad mobile pairing local network sync" + case .computers: + return "\(title) computers devices macs remote pair unpair presence online offline tailscale hive" + case .workspaceColors: + return "\(title) palette tabs" + case .sidebarAppearance: + return "\(title) sidebar details branches badges material terminal background" + case .customSidebars: + return "\(title) custom sidebars vibe swift json interpreted renderer in-process remote worker isolated" + case .betaFeatures: + return "\(title) beta experimental unstable feed dock right sidebar" + case .automation: + return "\(title) socket integrations hooks ports claude cursor gemini kiro naming auto naming workspace tabs" + case .browser: + return "\(title) search engine links history theme" + case .browserImport: + return "\(title) browser import data bookmarks history cookies" + case .globalHotkey: + return "\(title) system wide shortcut" + case .keyboardShortcuts: + return "\(title) keybindings commands chords" + case .settingsJSON: + return "\(title) config file preferences editor documentation schema jsonc reload" + case .reset: + return "\(title) defaults" + } + } +} diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index c609753b015d..c7366ae3f068 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -15,6 +15,8 @@ enum SettingsSearchAliasIndex { return localized("settings.search.alias.section.sleepyMode", defaultValue: "sleepy mode screensaver caffeinate keep awake do not sleep lock touch id battery wifi clock mascot theme glow pixel night") case .mobile: return localized("settings.search.alias.section.mobile", defaultValue: "ios iphone ipad mobile pairing local network permission sync") + case .computers: + return localized("settings.search.alias.section.computers", defaultValue: "computers devices macs remote view control pair unpair presence online offline tailscale hive") case .sidebarAppearance: return localized("settings.search.alias.section.sidebarAppearance", defaultValue: "sidebar left rail navigation details branches badges material terminal background") case .customSidebars: diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index ea449a5a39f1..004814c79551 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -126,6 +126,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */; }; C4A570010000000000000001 /* AppDelegate+CanvasShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4A570010000000000000002 /* AppDelegate+CanvasShortcutRouting.swift */; }; C4160A030000000000000001 /* AppDelegate+ClosedItemHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A030000000000000002 /* AppDelegate+ClosedItemHistory.swift */; }; + C41FE00000000000000000C3 /* AppDelegate+CloudClients.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D3 /* AppDelegate+CloudClients.swift */; }; C54860050000000000000001 /* AppDelegate+CmuxNavigationDeepLinks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860050000000000000002 /* AppDelegate+CmuxNavigationDeepLinks.swift */; }; C3677004000000000000001 /* AppDelegate+CmuxSSHURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */; }; C65930010000000000000003 /* AppDelegate+CrashSessionSnapshotRemoval.swift in Sources */ = {isa = PBXBuildFile; fileRef = C65930010000000000000004 /* AppDelegate+CrashSessionSnapshotRemoval.swift */; }; @@ -539,6 +540,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C617000000000000000000A3 /* CmuxGit in Frameworks */ = {isa = PBXBuildFile; productRef = C617000000000000000000A2 /* CmuxGit */; }; C0DE34020000000000000001 /* CmuxHelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000003 /* CmuxHelpCommands.swift */; }; C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000004 /* CmuxHelpResource.swift */; }; + C41FE00000000000000000B1 /* CmuxHive in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A2 /* CmuxHive */; }; + C41FE00000000000000000B2 /* CmuxHive in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A2 /* CmuxHive */; }; C0DE46010000000000000001 /* CMUXInstalledExtensionSidebarHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */; }; E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */; }; 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; @@ -852,9 +855,11 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000006 /* HelpMenuUITests.swift */; }; F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */; }; 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */; }; + C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D2 /* HiveComputersService.swift */; }; CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */; }; B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */; }; + C41FE00000000000000000C1 /* HostSettingsActions+Computers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D1 /* HostSettingsActions+Computers.swift */; }; CD0CFE6200000000CD0CFE62 /* HostSettingsActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */; }; F1C1AA21B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */; }; DA7A10CA710E000000000004 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000002 /* InfoPlist.xcstrings */; }; @@ -1312,6 +1317,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D9CCF002D9CCF002D9CCF002 /* SettingsLazyLoadHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */; }; D5671401D5671401D5671401 /* SettingsNavigation+TerminalScrollSpeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5671402D5671402D5671402 /* SettingsNavigation+TerminalScrollSpeed.swift */; }; A50019A0 /* SettingsNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019A1 /* SettingsNavigation.swift */; }; + C41FE00000000000000000C4 /* SettingsNavigationTarget+SearchText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D4 /* SettingsNavigationTarget+SearchText.swift */; }; A50019B0 /* SettingsSearchAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B1 /* SettingsSearchAliases.swift */; }; A50019B2 /* SettingsSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B3 /* SettingsSearchIndexTests.swift */; }; D10000000000000000A0001C /* SettingsShortcutsBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A0001D /* SettingsShortcutsBehaviorUITests.swift */; }; @@ -2039,6 +2045,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+AgentChatNotifications.swift"; sourceTree = ""; }; C4A570010000000000000002 /* AppDelegate+CanvasShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CanvasShortcutRouting.swift"; sourceTree = ""; }; C4160A030000000000000002 /* AppDelegate+ClosedItemHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+ClosedItemHistory.swift"; sourceTree = ""; }; + C41FE00000000000000000D3 /* AppDelegate+CloudClients.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CloudClients.swift"; sourceTree = ""; }; C54860050000000000000002 /* AppDelegate+CmuxNavigationDeepLinks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CmuxNavigationDeepLinks.swift"; sourceTree = ""; }; C3677004000000000000002 /* AppDelegate+CmuxSSHURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CmuxSSHURL.swift"; sourceTree = ""; }; C65930010000000000000004 /* AppDelegate+CrashSessionSnapshotRemoval.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CrashSessionSnapshotRemoval.swift"; sourceTree = ""; }; @@ -2711,9 +2718,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE34020000000000000006 /* HelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpMenuUITests.swift; sourceTree = ""; }; F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesAgentIndex.swift; sourceTree = ""; }; 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenRightSidebarContentMountingTests.swift; sourceTree = ""; }; + C41FE00000000000000000D2 /* HiveComputersService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputersService.swift; sourceTree = ""; }; CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/HostedInspectorDockControlScript.swift; sourceTree = ""; }; B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedInspectorDockControlScriptTests.swift; sourceTree = ""; }; + C41FE00000000000000000D1 /* HostSettingsActions+Computers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "HostSettingsActions+Computers.swift"; sourceTree = ""; }; CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostSettingsActions.swift; sourceTree = ""; }; F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactivePaneFirstClickFocusTests.swift; sourceTree = ""; }; DA7A10CA710E000000000002 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; @@ -3160,6 +3169,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D9CCF001D9CCF001D9CCF001 /* SettingsLazyLoadHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsLazyLoadHelpers.swift; sourceTree = ""; }; D5671402D5671402D5671402 /* SettingsNavigation+TerminalScrollSpeed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsNavigation+TerminalScrollSpeed.swift"; sourceTree = ""; }; A50019A1 /* SettingsNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigation.swift; sourceTree = ""; }; + C41FE00000000000000000D4 /* SettingsNavigationTarget+SearchText.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "SettingsNavigationTarget+SearchText.swift"; sourceTree = ""; }; A50019B1 /* SettingsSearchAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchAliases.swift; sourceTree = ""; }; A50019B3 /* SettingsSearchIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchIndexTests.swift; sourceTree = ""; }; D10000000000000000A0001D /* SettingsShortcutsBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsShortcutsBehaviorUITests.swift; sourceTree = ""; }; @@ -3711,6 +3721,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C9A2C00000000000000000C3 /* CmuxFeedback in Frameworks */, CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, C617000000000000000000A3 /* CmuxGit in Frameworks */, + C41FE00000000000000000B1 /* CmuxHive in Frameworks */, 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */, E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */, E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */, @@ -3784,6 +3795,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55500000000000000000E4 /* CmuxCommandPalette in Frameworks */, CC0DE00000000000000000A4 /* CmuxCore in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, + C41FE00000000000000000B2 /* CmuxHive in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */, 730600040000000000000001 /* CmuxWindowing in Frameworks */, @@ -3934,6 +3946,15 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = path = Mobile; sourceTree = ""; }; + C41FE00000000000000000E1 /* Hive */ = { + isa = PBXGroup; + children = ( + C41FE00000000000000000D2 /* HiveComputersService.swift */, + ); + name = Hive; + path = Hive; + sourceTree = ""; + }; 590E4F5F342E5B27010ECC8D /* Cloud */ = { isa = PBXGroup; children = ( @@ -4009,6 +4030,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE34020000000000000003 /* CmuxHelpCommands.swift */, C0DE34020000000000000004 /* CmuxHelpResource.swift */, A50019A1 /* SettingsNavigation.swift */, + C41FE00000000000000000D4 /* SettingsNavigationTarget+SearchText.swift */, D5671402D5671402D5671402 /* SettingsNavigation+TerminalScrollSpeed.swift */, A50019B1 /* SettingsSearchAliases.swift */, D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */, @@ -4564,6 +4586,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + C41FE00000000000000000D3 /* AppDelegate+CloudClients.swift */, C0DE45AC0000000000000002 /* AppDelegate+AgentChat.swift */, C0DE71A00000000000000002 /* AgentChatThemeSync.swift */, C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */, @@ -4860,6 +4883,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001241 /* WindowDecorationsController.swift */, A5001222 /* WindowAccessor.swift */, CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */, + C41FE00000000000000000D1 /* HostSettingsActions+Computers.swift */, F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */, F6572011A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift */, A74770010000000000000006 /* SessionConfigFrameEntry.swift */, @@ -5053,6 +5077,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = FEED0000000000000000F003 /* Feed */, C57B00040000000000000002 /* TerminalController+CustomSidebarCommands.swift */, C57B00050000000000000002 /* CmuxExtensionSidebarSelection+CustomSidebarName.swift */, + C41FE00000000000000000E1 /* Hive */, 59065015952E1B5BE5E23519 /* Mobile */, 31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */, ); @@ -5741,6 +5766,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CC0DE30000000000000000A2 /* CmuxRemoteSession */, A8BD195031FC4B82B4354297 /* StackAuth */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, + C41FE00000000000000000A2 /* CmuxHive */, ); productName = cmux; productReference = A5001000 /* cmux.app */; @@ -5824,6 +5850,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = packageProductDependencies = ( A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, + C41FE00000000000000000A2 /* CmuxHive */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, 5E2701040000000000000001 /* Sentry */, 5E2701040000000000000002 /* CmuxFoundation */, @@ -5900,6 +5927,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55200000000000000000B1 /* XCLocalSwiftPackageReference "CmuxWindowing" */, 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, + C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, @@ -6177,6 +6205,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources */, C4A570010000000000000001 /* AppDelegate+CanvasShortcutRouting.swift in Sources */, C4160A030000000000000001 /* AppDelegate+ClosedItemHistory.swift in Sources */, + C41FE00000000000000000C3 /* AppDelegate+CloudClients.swift in Sources */, C54860050000000000000001 /* AppDelegate+CmuxNavigationDeepLinks.swift in Sources */, C3677004000000000000001 /* AppDelegate+CmuxSSHURL.swift in Sources */, C65930010000000000000003 /* AppDelegate+CrashSessionSnapshotRemoval.swift in Sources */, @@ -6550,8 +6579,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC72010000000000000001 /* GPUSpinnerNSView.swift in Sources */, A6AC72020000000000000001 /* GPUSpinnerStyle.swift in Sources */, F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */, + C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */, + C41FE00000000000000000C1 /* HostSettingsActions+Computers.swift in Sources */, CD0CFE6200000000CD0CFE62 /* HostSettingsActions.swift in Sources */, C0DEA7720000000000000001 /* InternalFlagsWindow.swift in Sources */, 4D8B37E0E11A29997632765F /* InternalTabDragConfiguration.swift in Sources */, @@ -6853,6 +6884,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = D9CCF002D9CCF002D9CCF002 /* SettingsLazyLoadHelpers.swift in Sources */, D5671401D5671401D5671401 /* SettingsNavigation+TerminalScrollSpeed.swift in Sources */, A50019A0 /* SettingsNavigation.swift in Sources */, + C41FE00000000000000000C4 /* SettingsNavigationTarget+SearchText.swift in Sources */, A50019B0 /* SettingsSearchAliases.swift in Sources */, D36090030000000000000001 /* SettingsWindowFactory.swift in Sources */, D37777030000000000000001 /* SettingsWindowGeometry.swift in Sources */, @@ -8276,6 +8308,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CMUXMobileCore; }; + C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxHive; + }; 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */ = { isa = XCLocalSwiftPackageReference; relativePath = "vendor/stack-auth-swift-sdk-prerelease"; @@ -8743,6 +8779,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = 0C69E6A5FA9B0513E68DF443 /* XCLocalSwiftPackageReference "CMUXMobileCore" */; productName = CMUXMobileCore; }; + C41FE00000000000000000A2 /* CmuxHive */ = { + isa = XCSwiftPackageProductDependency; + package = C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */; + productName = CmuxHive; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A5001070 /* Project object */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 96ee0af19eed..aa9481a294d9 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -129,6 +129,9 @@ + + From ef3bdc5eb2b224f7e278864fdf3f22154d8833e4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 18:23:14 -0700 Subject: [PATCH 02/97] Declare CmuxMobileShell and CmuxMobilePairedMac products on the app target HiveComputersService.swift imports both modules directly, but only CmuxHive was a declared product dependency, so the linker did not reliably pull their static archives into the app dylib (undefined symbols for DeviceRegistryService / PresenceClient / MobilePairedMacStore on CI). Declare the products the app imports, on both the cmux and cmuxTests targets. Part of #8001 Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 004814c79551..cf8573a3f8c6 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -549,6 +549,10 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D36090020000000000000005 /* CmuxMainWindowFullScreenCapabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */; }; 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; + C41FE00000000000000000B5 /* CmuxMobilePairedMac in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A6 /* CmuxMobilePairedMac */; }; + C41FE00000000000000000B6 /* CmuxMobilePairedMac in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A6 /* CmuxMobilePairedMac */; }; + C41FE00000000000000000B7 /* CmuxMobileShell in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A8 /* CmuxMobileShell */; }; + C41FE00000000000000000B8 /* CmuxMobileShell in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A8 /* CmuxMobileShell */; }; CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */; }; C54860110000000000000001 /* CmuxNavigationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860110000000000000002 /* CmuxNavigationResolution.swift */; }; C54860120000000000000001 /* CmuxNavigationSurfaceDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860120000000000000002 /* CmuxNavigationSurfaceDescriptor.swift */; }; @@ -3723,6 +3727,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C617000000000000000000A3 /* CmuxGit in Frameworks */, C41FE00000000000000000B1 /* CmuxHive in Frameworks */, 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */, + C41FE00000000000000000B5 /* CmuxMobilePairedMac in Frameworks */, + C41FE00000000000000000B7 /* CmuxMobileShell in Frameworks */, E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */, E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */, A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */, @@ -3797,6 +3803,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, C41FE00000000000000000B2 /* CmuxHive in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, + C41FE00000000000000000B6 /* CmuxMobilePairedMac in Frameworks */, + C41FE00000000000000000B8 /* CmuxMobileShell in Frameworks */, C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */, 730600040000000000000001 /* CmuxWindowing in Frameworks */, 5E2701040000000000000003 /* Sentry in Frameworks */, @@ -5767,6 +5775,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A8BD195031FC4B82B4354297 /* StackAuth */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, + C41FE00000000000000000A6 /* CmuxMobilePairedMac */, + C41FE00000000000000000A8 /* CmuxMobileShell */, ); productName = cmux; productReference = A5001000 /* cmux.app */; @@ -5851,6 +5861,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, + C41FE00000000000000000A6 /* CmuxMobilePairedMac */, + C41FE00000000000000000A8 /* CmuxMobileShell */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, 5E2701040000000000000001 /* Sentry */, 5E2701040000000000000002 /* CmuxFoundation */, @@ -5928,6 +5940,8 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */, + C41FE00000000000000000A5 /* XCLocalSwiftPackageReference "CmuxMobilePairedMac" */, + C41FE00000000000000000A7 /* XCLocalSwiftPackageReference "CmuxMobileShell" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, @@ -8312,6 +8326,14 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxHive; }; + C41FE00000000000000000A5 /* XCLocalSwiftPackageReference "CmuxMobilePairedMac" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/iOS/CmuxMobilePairedMac; + }; + C41FE00000000000000000A7 /* XCLocalSwiftPackageReference "CmuxMobileShell" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/iOS/CmuxMobileShell; + }; 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */ = { isa = XCLocalSwiftPackageReference; relativePath = "vendor/stack-auth-swift-sdk-prerelease"; @@ -8784,6 +8806,16 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */; productName = CmuxHive; }; + C41FE00000000000000000A6 /* CmuxMobilePairedMac */ = { + isa = XCSwiftPackageProductDependency; + package = C41FE00000000000000000A5 /* XCLocalSwiftPackageReference "CmuxMobilePairedMac" */; + productName = CmuxMobilePairedMac; + }; + C41FE00000000000000000A8 /* CmuxMobileShell */ = { + isa = XCSwiftPackageProductDependency; + package = C41FE00000000000000000A7 /* XCLocalSwiftPackageReference "CmuxMobileShell" */; + productName = CmuxMobileShell; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A5001070 /* Project object */; From c0db87ba517da09683c5dda355733c50c019cb19 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 19:02:29 -0700 Subject: [PATCH 03/97] Revert "Declare CmuxMobileShell and CmuxMobilePairedMac products on the app target" This reverts commit 509b1e333418176dacad229ee264ec7ceae77da8. --- cmux.xcodeproj/project.pbxproj | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cf8573a3f8c6..004814c79551 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -549,10 +549,6 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources D36090020000000000000005 /* CmuxMainWindowFullScreenCapabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */; }; 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; - C41FE00000000000000000B5 /* CmuxMobilePairedMac in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A6 /* CmuxMobilePairedMac */; }; - C41FE00000000000000000B6 /* CmuxMobilePairedMac in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A6 /* CmuxMobilePairedMac */; }; - C41FE00000000000000000B7 /* CmuxMobileShell in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A8 /* CmuxMobileShell */; }; - C41FE00000000000000000B8 /* CmuxMobileShell in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A8 /* CmuxMobileShell */; }; CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */; }; C54860110000000000000001 /* CmuxNavigationResolution.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860110000000000000002 /* CmuxNavigationResolution.swift */; }; C54860120000000000000001 /* CmuxNavigationSurfaceDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C54860120000000000000002 /* CmuxNavigationSurfaceDescriptor.swift */; }; @@ -3727,8 +3723,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C617000000000000000000A3 /* CmuxGit in Frameworks */, C41FE00000000000000000B1 /* CmuxHive in Frameworks */, 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */, - C41FE00000000000000000B5 /* CmuxMobilePairedMac in Frameworks */, - C41FE00000000000000000B7 /* CmuxMobileShell in Frameworks */, E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */, E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */, A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */, @@ -3803,8 +3797,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, C41FE00000000000000000B2 /* CmuxHive in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, - C41FE00000000000000000B6 /* CmuxMobilePairedMac in Frameworks */, - C41FE00000000000000000B8 /* CmuxMobileShell in Frameworks */, C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */, 730600040000000000000001 /* CmuxWindowing in Frameworks */, 5E2701040000000000000003 /* Sentry in Frameworks */, @@ -5775,8 +5767,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A8BD195031FC4B82B4354297 /* StackAuth */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, - C41FE00000000000000000A6 /* CmuxMobilePairedMac */, - C41FE00000000000000000A8 /* CmuxMobileShell */, ); productName = cmux; productReference = A5001000 /* cmux.app */; @@ -5861,8 +5851,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, - C41FE00000000000000000A6 /* CmuxMobilePairedMac */, - C41FE00000000000000000A8 /* CmuxMobileShell */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, 5E2701040000000000000001 /* Sentry */, 5E2701040000000000000002 /* CmuxFoundation */, @@ -5940,8 +5928,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */, - C41FE00000000000000000A5 /* XCLocalSwiftPackageReference "CmuxMobilePairedMac" */, - C41FE00000000000000000A7 /* XCLocalSwiftPackageReference "CmuxMobileShell" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, @@ -8326,14 +8312,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxHive; }; - C41FE00000000000000000A5 /* XCLocalSwiftPackageReference "CmuxMobilePairedMac" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Packages/iOS/CmuxMobilePairedMac; - }; - C41FE00000000000000000A7 /* XCLocalSwiftPackageReference "CmuxMobileShell" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Packages/iOS/CmuxMobileShell; - }; 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */ = { isa = XCLocalSwiftPackageReference; relativePath = "vendor/stack-auth-swift-sdk-prerelease"; @@ -8806,16 +8784,6 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */; productName = CmuxHive; }; - C41FE00000000000000000A6 /* CmuxMobilePairedMac */ = { - isa = XCSwiftPackageProductDependency; - package = C41FE00000000000000000A5 /* XCLocalSwiftPackageReference "CmuxMobilePairedMac" */; - productName = CmuxMobilePairedMac; - }; - C41FE00000000000000000A8 /* CmuxMobileShell */ = { - isa = XCSwiftPackageProductDependency; - package = C41FE00000000000000000A7 /* XCLocalSwiftPackageReference "CmuxMobileShell" */; - productName = CmuxMobileShell; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A5001070 /* Project object */; From cb93c83cd6f0f23289b9e5227a002a8b6165efe5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 19:04:24 -0700 Subject: [PATCH 04/97] Compose the computers directory inside CmuxHive so the app links only CmuxHive The app-side HiveComputersService named DeviceRegistryService / PresenceClient / MobilePairedMacStore directly, which required declaring CmuxMobileShell and CmuxMobilePairedMac as app-target package products. That perturbed Xcode's package-product linkage graph: with the products declared, CmuxAgentChat symbols moved out of the app binary's exports and the cmuxTests bundle (which resolves them via bundle_loader) failed to link on CI; without them, HiveComputersService.o itself had undefined symbols. Fix the class of problem instead of the instance: a new HiveComposition factory inside CmuxHive names the concrete client-stack types, and the app's composition root supplies configuration only (URLs, token closures, device identity, loopback policy). The app target now imports and links nothing below CmuxHive, so the pre-existing app/test linkage graph is untouched (the product-dependency commit is reverted). Also: HiveReconnectBackoff (instantiable bounded backoff shared by the directory's presence resubscribe), and internal-import tightening that clears public-import warnings. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../Sources/CmuxHive/HiveComposition.swift | 117 ++++++++++++++++++ .../Sources/CmuxHive/HiveComputer.swift | 2 +- .../CmuxHive/HiveComputerDirectory.swift | 2 +- .../CmuxHive/HiveReconnectBackoff.swift | 24 ++++ Sources/Hive/HiveComputersService.swift | 84 +++---------- 5 files changed, 163 insertions(+), 66 deletions(-) create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift new file mode 100644 index 000000000000..6a0ff703eef0 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift @@ -0,0 +1,117 @@ +internal import CmuxMobilePairedMac +internal import CmuxMobileShell +import Foundation + +/// Builds the hive computers directory from app-supplied configuration, +/// naming the concrete registry / paired-store / presence implementations +/// inside the package. +/// +/// The app's composition root decides *what* to wire (URLs, tokens, device +/// identity, policy) and this factory owns *which concrete types* implement +/// the directory's seams. Keeping the concrete construction here means the +/// app target links only `CmuxHive` — it never has to declare (or even know +/// about) the client-stack packages the directory composes, so adding a new +/// source to the merge cannot ripple into the app's package-product graph. +@MainActor +public struct HiveComposition { + /// Everything the directory's sources need, supplied by the app root. + public struct Configuration: Sendable { + /// The cmux web API base URL, no trailing slash (`/api/devices` host). + public var apiBaseURL: String + /// The presence worker base URL, or `nil` to run without live presence. + public var presenceBaseURL: String? + /// This computer's stable registry device id. + public var ownDeviceID: String + /// Accept loopback pairing links/routes (dev builds only). + public var allowsLoopbackRoutes: Bool + /// Returns the current Stack access token, or `nil` when signed out. + public var accessToken: @Sendable () async -> String? + /// Returns the current Stack refresh token, or `nil` when signed out. + public var refreshToken: @Sendable () async -> String? + /// Returns the signed-in Stack user id, or `nil` when signed out. + public var currentUserID: @Sendable () async -> String? + /// Returns the selected team id, or `nil` for the default scope. + public var teamID: @Sendable () async -> String? + + public init( + apiBaseURL: String, + presenceBaseURL: String?, + ownDeviceID: String, + allowsLoopbackRoutes: Bool, + accessToken: @escaping @Sendable () async -> String?, + refreshToken: @escaping @Sendable () async -> String?, + currentUserID: @escaping @Sendable () async -> String?, + teamID: @escaping @Sendable () async -> String? + ) { + self.apiBaseURL = apiBaseURL + self.presenceBaseURL = presenceBaseURL + self.ownDeviceID = ownDeviceID + self.allowsLoopbackRoutes = allowsLoopbackRoutes + self.accessToken = accessToken + self.refreshToken = refreshToken + self.currentUserID = currentUserID + self.teamID = teamID + } + } + + private let configuration: Configuration + + /// Creates a composition over the app-supplied configuration. + public init(configuration: Configuration) { + self.configuration = configuration + } + + /// Builds the merged computers directory: the team device registry, the + /// local paired-computer store, and (when configured) the live presence + /// stream. + /// + /// - Throws: The paired-computer store's error when its SQLite database + /// cannot be opened. + public func makeDirectory() throws -> HiveComputerDirectory { + let configuration = configuration + let store = try MobilePairedMacStore(databaseURL: Self.pairedComputersDatabaseURL()) + let registry = DeviceRegistryService( + apiBaseURL: configuration.apiBaseURL, + deviceID: configuration.ownDeviceID, + tokenSource: DeviceRegistryService.TokenSource( + accessToken: configuration.accessToken, + refreshToken: configuration.refreshToken + ), + teamIDProvider: configuration.teamID + ) + let presence: (any PresenceSubscribing)? + if let presenceBaseURL = configuration.presenceBaseURL { + presence = PresenceClient( + serviceBaseURL: presenceBaseURL, + tokenSource: PresenceTokenSource( + accessToken: configuration.accessToken, + currentUserID: configuration.currentUserID + ), + teamIDProvider: configuration.teamID + ) + } else { + presence = nil + } + return HiveComputerDirectory( + registry: registry, + pairedStore: store, + presence: presence, + ownDeviceID: configuration.ownDeviceID, + scopeProvider: { [currentUserID = configuration.currentUserID, teamID = configuration.teamID] in + await HiveAccountScope(stackUserID: currentUserID(), teamID: teamID()) + }, + linkDecoder: HivePairingLinkDecoder( + allowsLoopbackRoutes: configuration.allowsLoopbackRoutes + ), + presenceRetryDelay: HiveReconnectBackoff(maximumSeconds: 60).delay(attempt:) + ) + } + + /// The Mac-side paired-computer database. Its own file (not the phone's + /// `paired-macs.sqlite3` name) so the Mac-as-client namespace is distinct. + private static func pairedComputersDatabaseURL() throws -> URL { + try MobilePairedMacStore.defaultDatabaseURL() + .deletingLastPathComponent() + .appendingPathComponent("hive-paired-computers.sqlite3") + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift index 52831d02bd0e..041de34697b0 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift @@ -1,5 +1,5 @@ public import CMUXMobileCore -public import Foundation +import Foundation /// One computer on the signed-in account, merged from the device registry, /// the local paired-computer store, and the live presence map. diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index 6ed0ef7bfe3a..826b36d06114 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -1,4 +1,4 @@ -public import CMUXMobileCore +internal import CMUXMobileCore public import CmuxMobilePairedMac public import CmuxMobileShell public import CmuxMobileShellModel diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift new file mode 100644 index 000000000000..6fdfcd36d48e --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Bounded exponential reconnect backoff for hive sessions: 1s doubling to a +/// configurable cap. +/// +/// A genuine, cancellable delay (not a poll): each awaited attempt is a real +/// reconnect, and cancelling the owning task cancels the pending sleep. +public struct HiveReconnectBackoff: Sendable { + /// The longest single delay, in seconds. + public var maximumSeconds: Double + + /// Creates a backoff capped at `maximumSeconds`. + public init(maximumSeconds: Double = 30) { + self.maximumSeconds = maximumSeconds + } + + /// Await the backoff for the given consecutive-failure attempt count. + public func delay(attempt: Int) async { + let seconds = min(maximumSeconds, pow(2.0, Double(min(max(attempt - 1, 0), 6)))) + // Bounded, cancellable delay — the intended behavior, per the + // Clock.sleep carve-out. + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } +} diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index b6d32f59757f..f55574700506 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -1,18 +1,17 @@ import CmuxAuthRuntime import CmuxHive -import CmuxMobilePairedMac -import CmuxMobileShell import Foundation -/// App-side composition of the hive computers directory: wires the shared -/// device-registry client, the local paired-computer store, and the presence -/// subscriber (the same client stack the iOS app composes) to the signed-in -/// account, and hands the resulting ``HiveComputerDirectory`` to the -/// Settings › Computers pane. +/// App-side owner of the hive computers directory (the merged registry + +/// pairings + presence list behind Settings › Computers). /// -/// Follows the house pattern of the other cloud clients configured at the -/// composition root (``DeviceRegistryClient``, ``PresenceHeartbeatClient``): -/// a `shared` instance that stays inert until `configure(auth:)`. +/// The composition root supplies configuration only — URLs, tokens, device +/// identity, loopback policy — and `HiveComposition` (in the CmuxHive +/// package) names the concrete client-stack types, so the app target depends +/// on nothing below CmuxHive. Follows the house pattern of the other cloud +/// clients configured at the root (``DeviceRegistryClient``, +/// ``PresenceHeartbeatClient``): a `shared` instance that stays inert until +/// `configure(auth:)`. @MainActor final class HiveComputersService { static let shared = HiveComputersService() @@ -33,55 +32,21 @@ final class HiveComputersService { func configure(auth: AuthCoordinator) { self.auth = auth guard directory == nil else { return } - let store: MobilePairedMacStore + let composition = HiveComposition(configuration: HiveComposition.Configuration( + apiBaseURL: Self.normalizedBaseURL(AuthEnvironment.vmAPIBaseURL), + presenceBaseURL: PresenceHeartbeatClient.resolvedServiceURL().map(Self.normalizedBaseURL), + ownDeviceID: MobileHostIdentity.deviceID(), + allowsLoopbackRoutes: Self.allowsLoopbackPairing, + accessToken: { (try? await auth.currentTokens())?.accessToken }, + refreshToken: { (try? await auth.currentTokens())?.refreshToken }, + currentUserID: { await auth.currentUser?.id }, + teamID: { await auth.resolvedTeamID } + )) do { - store = try MobilePairedMacStore(databaseURL: Self.pairedComputersDatabaseURL()) + directory = try composition.makeDirectory() } catch { NSLog("cmux.hive paired-computer store unavailable: %@", String(describing: error)) - return - } - let apiBaseURL = Self.normalizedBaseURL(AuthEnvironment.vmAPIBaseURL) - let registry = DeviceRegistryService( - apiBaseURL: apiBaseURL, - deviceID: MobileHostIdentity.deviceID(), - tokenSource: DeviceRegistryService.TokenSource( - accessToken: { (try? await auth.currentTokens())?.accessToken }, - refreshToken: { (try? await auth.currentTokens())?.refreshToken } - ), - teamIDProvider: { await auth.resolvedTeamID } - ) - let presence: PresenceClient? - if let presenceURL = PresenceHeartbeatClient.resolvedServiceURL() { - presence = PresenceClient( - serviceBaseURL: Self.normalizedBaseURL(presenceURL), - tokenSource: PresenceTokenSource( - accessToken: { (try? await auth.currentTokens())?.accessToken }, - currentUserID: { await auth.currentUser?.id } - ), - teamIDProvider: { await auth.resolvedTeamID } - ) - } else { - presence = nil } - directory = HiveComputerDirectory( - registry: registry, - pairedStore: store, - presence: presence, - ownDeviceID: MobileHostIdentity.deviceID(), - scopeProvider: { - await HiveAccountScope( - stackUserID: auth.currentUser?.id, - teamID: auth.resolvedTeamID - ) - }, - linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: Self.allowsLoopbackPairing), - presenceRetryDelay: { attempt in - // Bounded, cancellable resubscribe backoff for the presence - // stream (a genuine delay, not a poll): 1s doubling to 60s. - let seconds = min(60.0, pow(2.0, Double(min(attempt, 6)))) - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - } - ) } /// Dev builds may pair over loopback so two instances on one machine can @@ -94,15 +59,6 @@ final class HiveComputersService { #endif } - /// The Mac-side paired-computer database. Uses its own file (not the - /// phone's `paired-macs.sqlite3` name) so the namespace stays clearly - /// Mac-as-client. - private static func pairedComputersDatabaseURL() throws -> URL { - try MobilePairedMacStore.defaultDatabaseURL() - .deletingLastPathComponent() - .appendingPathComponent("hive-paired-computers.sqlite3") - } - private static func normalizedBaseURL(_ url: URL) -> String { let raw = url.absoluteString return raw.hasSuffix("/") ? String(raw.dropLast()) : raw From 77a7cd406ce7076ad303cc3786e47c3a0956eca8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 19:18:01 -0700 Subject: [PATCH 05/97] Link CmuxAgentChat into cmuxTests directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding CmuxHive (whose closure includes CmuxAgentChat via CmuxMobileShell) to both the app and test targets makes Xcode build CmuxAgentChat as part of a dynamic package framework instead of statically into the app binary, so the app stops exporting its symbols and the test bundle — which imports CmuxAgentChat but had no product dependency of its own, resolving via bundle_loader — fails to link (every undefined symbol on CI is a CmuxAgentChat one). Declare the product on cmuxTests like the other shared packages so the test bundle links it directly. Part of #8001 Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 004814c79551..316b7d194300 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -419,6 +419,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B9000034A1B2C3D4E5F60719 /* cmux_open.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* cmux_open.swift */; }; A5001654 /* CmuxActionTrust.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001655 /* CmuxActionTrust.swift */; }; AC4A6E7C4A700001AC4A0001 /* CmuxAgentChat in Frameworks */ = {isa = PBXBuildFile; productRef = AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */; }; + AC4A6E7C4A700001AC4A0004 /* CmuxAgentChat in Frameworks */ = {isa = PBXBuildFile; productRef = AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */; }; C0DE43000000000000000001 /* CmuxAgentChatConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE43000000000000000002 /* CmuxAgentChatConfig.swift */; }; C0DE43000000000000000009 /* CmuxAgentChatConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE4300000000000000000A /* CmuxAgentChatConfigTests.swift */; }; A5B00003A1B2C3D4E5F60718 /* CMUXAgentLaunch in Frameworks */ = {isa = PBXBuildFile; productRef = A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */; }; @@ -3789,6 +3790,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = buildActionMask = 2147483647; files = ( B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */, + AC4A6E7C4A700001AC4A0004 /* CmuxAgentChat in Frameworks */, C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */, D7032A060000000000000001 /* CmuxBrowser in Frameworks */, CA52A007CA52A007CA52A007 /* CmuxCanvas in Frameworks */, @@ -5849,6 +5851,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = name = cmuxTests; packageProductDependencies = ( A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, + AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, From 72450f936f5a2faa244a73cf976dc8570f9bdb02 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 19:32:06 -0700 Subject: [PATCH 06/97] Make the composition's retry-delay closure explicitly @Sendable An unapplied method reference is not inferred @Sendable, which trips the Swift warning budget on the app build. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift index 6a0ff703eef0..67f97780b810 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift @@ -103,7 +103,9 @@ public struct HiveComposition { linkDecoder: HivePairingLinkDecoder( allowsLoopbackRoutes: configuration.allowsLoopbackRoutes ), - presenceRetryDelay: HiveReconnectBackoff(maximumSeconds: 60).delay(attempt:) + presenceRetryDelay: { @Sendable attempt in + await HiveReconnectBackoff(maximumSeconds: 60).delay(attempt: attempt) + } ) } From 9ab3b364ad631d5458b4d6067c3667e0a1dc6b94 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 10:49:55 -0700 Subject: [PATCH 07/97] Computers: copy this Mac's pairing link for Mac-to-Mac pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only way to pair Mac-to-Mac was "Show Pairing Code…", which opens the iPhone QR window — a Mac has no camera pointed at it. Add a Copy Pairing Link button to the Pair This Mac row: it mints the same attach payload the QR encodes (Tailscale-only v2 grammar; dev builds fall back to an all-routes ticket so two tagged builds on one machine can pair over loopback), puts it on the clipboard, and shows inline feedback. Paste it into "Add by Pairing Link" on the other Mac. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../ComputersSettingsSnapshot.swift | 12 +++ .../Environment/SettingsHostActions.swift | 8 ++ .../Sections/ComputersSection.swift | 84 +++++++++++++++-- Resources/Localizable.xcstrings | 89 ++++++++++++++++++- Sources/HostSettingsActions+Computers.swift | 56 ++++++++++++ 5 files changed, 238 insertions(+), 11 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift index 915066b1b48e..5980093617a2 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift @@ -97,3 +97,15 @@ public enum ComputersPairResult: Sendable, Equatable { /// The pairing could not be saved. case failed } + +/// Result of minting + copying this Mac's pairing link, rendered inline. +public enum ComputersCopyLinkResult: Sendable, Equatable { + /// The link is on the clipboard, ready to paste on the other Mac. + case copied + /// This Mac has no Tailscale route another Mac could dial. + case needsTailscale + /// Pairing requires the Mac to be signed in. + case signedOut + /// The pairing listener or ticket mint failed. + case failed +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index ea2b81c417cc..f05de26d13a8 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -169,6 +169,11 @@ public protocol SettingsHostActions: AnyObject { /// Removes the local pairing for a computer. The registry row remains. func unpairComputer(deviceID: String) async + /// Mints this Mac's pairing link (the same payload the pairing window's + /// QR encodes) and puts it on the clipboard, so another Mac can pair by + /// pasting it — no camera involved. + func copyComputerPairingLink() async -> ComputersCopyLinkResult + /// Applies an explicitly-requested iOS pairing port, checking availability /// first so a port already in use leaves the running listener untouched. The /// Mobile section calls this from its **Apply** button and renders the @@ -256,6 +261,9 @@ public extension SettingsHostActions { /// Default no-op unpair for previews/tests. func unpairComputer(deviceID: String) async {} + /// Default failure for hosts without a pairing listener (previews/tests). + func copyComputerPairingLink() async -> ComputersCopyLinkResult { .failed } + /// Default: save-for-later, for hosts without a live mobile service (previews/tests). func applyMobilePairingPort(_ port: Int) async -> MobilePairingPortApplyResult { (1...65535).contains(port) ? .savedForLater(port: port) : .invalid(requestedPort: port) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index b104e18f3bf1..17ff13629b16 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -17,6 +17,10 @@ public struct ComputersSection: View { @State private var pairResult: ComputersPairResult? /// Guards overlapping link-pair submissions. @State private var isPairingLink = false + /// Result of the most recent copy-link action, shown under its row. + @State private var copyLinkResult: ComputersCopyLinkResult? + /// Guards overlapping copy-link mints. + @State private var isCopyingLink = false private let hostActions: SettingsHostActions @@ -49,7 +53,8 @@ public struct ComputersSection: View { pairingLinkRow pairResultCaption SettingsCardDivider() - showPairingCodeRow + pairThisMacRow + copyLinkResultCaption } } .task { @@ -150,22 +155,48 @@ public struct ComputersSection: View { } @ViewBuilder - private var showPairingCodeRow: some View { + private var pairThisMacRow: some View { SettingsCardRow( configurationReview: .action, searchAnchorID: "setting:computers:showPairingCode", String(localized: "settings.computers.showPairingCode", defaultValue: "Pair This Mac"), subtitle: String( localized: "settings.computers.showPairingCode.subtitle", - defaultValue: "Show this Mac's pairing code for another Mac or an iPhone to add it." - ) + defaultValue: "Copy a pairing link to paste on another Mac, or show a QR code for an iPhone." + ), + controlWidth: 320 ) { - Button(String(localized: "settings.computers.showPairingCode.button", defaultValue: "Show Pairing Code…")) { - hostActions.openMobilePairingWindow() + HStack(spacing: 8) { + Button(String(localized: "settings.computers.copyLink.button", defaultValue: "Copy Pairing Link")) { + copyPairingLink() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(isCopyingLink) + .accessibilityIdentifier("SettingsComputersCopyPairingLinkButton") + + Button(String(localized: "settings.computers.showPairingCode.button", defaultValue: "Show Pairing Code…")) { + hostActions.openMobilePairingWindow() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsComputersShowPairingCodeButton") } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsComputersShowPairingCodeButton") + } + } + + @ViewBuilder + private var copyLinkResultCaption: some View { + if let copyLinkResult, let text = Self.copyLinkText(copyLinkResult) { + Label( + text, + systemImage: copyLinkResult == .copied ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" + ) + .foregroundStyle(copyLinkResult == .copied ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) + .cmuxFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.bottom, 8) } } @@ -187,6 +218,16 @@ public struct ComputersSection: View { } } + private func copyPairingLink() { + guard !isCopyingLink else { return } + isCopyingLink = true + copyLinkResult = nil + Task { + copyLinkResult = await hostActions.copyComputerPairingLink() + isCopyingLink = false + } + } + private func pairFromLink() { let link = pairingLink.trimmingCharacters(in: .whitespacesAndNewlines) guard !isPairingLink, !link.isEmpty else { return } @@ -200,6 +241,31 @@ public struct ComputersSection: View { } } + private static func copyLinkText(_ result: ComputersCopyLinkResult) -> String? { + switch result { + case .copied: + return String( + localized: "settings.computers.copyLink.copied", + defaultValue: "Link copied. Paste it into “Add by Pairing Link” on the other Mac." + ) + case .needsTailscale: + return String( + localized: "settings.computers.copyLink.needsTailscale", + defaultValue: "No reachable address. Install and sign in to Tailscale on both Macs, then try again." + ) + case .signedOut: + return String( + localized: "settings.computers.copyLink.signedOut", + defaultValue: "Sign in to cmux first, then copy the pairing link." + ) + case .failed: + return String( + localized: "settings.computers.copyLink.failed", + defaultValue: "Couldn't create a pairing link. Try again." + ) + } + } + private static func resultText(_ result: ComputersPairResult) -> String? { switch result { case .paired: diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2e8a24a81429..e813729a3dc5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -235880,13 +235880,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Show this Mac's pairing code for another Mac or an iPhone to add it." + "value": "Copy a pairing link to paste on another Mac, or show a QR code for an iPhone." } }, "ja": { "stringUnit": { "state": "translated", - "value": "別の Mac や iPhone がこの Mac を追加できるように、ペアリングコードを表示します。" + "value": "別の Mac に貼り付けるペアリングリンクをコピーするか、iPhone 用の QR コードを表示します。" } } } @@ -236128,6 +236128,91 @@ } } } + }, + "settings.computers.copyLink.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Pairing Link" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングリンクをコピー" + } + } + } + }, + "settings.computers.copyLink.copied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Link copied. Paste it into “Add by Pairing Link” on the other Mac." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクをコピーしました。もう一方の Mac の「ペアリングリンクで追加」に貼り付けてください。" + } + } + } + }, + "settings.computers.copyLink.needsTailscale": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No reachable address. Install and sign in to Tailscale on both Macs, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続可能なアドレスがありません。両方の Mac に Tailscale をインストールしてサインインしてから、もう一度お試しください。" + } + } + } + }, + "settings.computers.copyLink.signedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to cmux first, then copy the pairing link." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "先に cmux にサインインしてから、ペアリングリンクをコピーしてください。" + } + } + } + }, + "settings.computers.copyLink.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't create a pairing link. Try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリングリンクを作成できませんでした。もう一度お試しください。" + } + } + } } }, "version": "1.0" diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index eb8904f2ef5a..aba7befd271b 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxHive import CmuxSettingsUI import Foundation @@ -61,6 +62,61 @@ extension HostSettingsActions { await directory.unpair(deviceID: deviceID) } + /// Mints this Mac's attach link (the same payload the pairing window's QR + /// encodes) and copies it to the clipboard for pasting on another Mac. + /// + /// Prefers the Tailscale-only v2 grammar (works across machines). Dev + /// builds fall back to an all-routes ticket when no Tailscale route + /// exists, so two tagged builds on one machine can pair over loopback. + func copyComputerPairingLink() async -> ComputersCopyLinkResult { + guard let coordinator = AppDelegate.shared?.auth?.coordinator else { return .failed } + await coordinator.awaitBootstrapped() + guard coordinator.isAuthenticated else { return .signedOut } + UserDefaults.standard.set(true, forKey: MobileHostService.listeningEnabledDefaultsKey) + let host = MobileHostService.shared + let status = await host.ensureListeningAndReady() + guard status.isRunning else { return .failed } + do { + return copyLinkToPasteboard(try await host.createAttachTicket( + workspaceID: "", + terminalID: nil, + ttl: 600, + target: .physicalDevice + )) + } catch MobileAttachTicketStoreError.noRoutes, + MobileAttachTicketStoreError.routeUnavailable, + MobileAttachTicketStoreError.invalidAttachURL { + #if DEBUG + // Same-machine dogfood: no Tailscale route, but the dev loopback + // route lets a second tagged build on this Mac pair. + do { + return copyLinkToPasteboard(try await host.createAttachTicket( + workspaceID: "", + terminalID: nil, + ttl: 600, + target: .ticketOnly + )) + } catch { + return .needsTailscale + } + #else + return .needsTailscale + #endif + } catch { + return .failed + } + } + + private func copyLinkToPasteboard(_ payload: [String: Any]) -> ComputersCopyLinkResult { + guard let attachURL = payload["attach_url"] as? String, !attachURL.isEmpty else { + return .failed + } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(attachURL, forType: .string) + return .copied + } + // MARK: - Mapping (pure) /// Map the merged directory rows into the settings package's snapshot. From 003e87e7bea15c4fb12ec303f02b1ba463c8000c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 12:15:06 -0700 Subject: [PATCH 08/97] Computers: drop the iPhone QR button from Pair This Mac Mac-to-Mac pairing is the copy-link flow; the QR window is an iPhone concern and stays reachable from Settings > Mobile. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../Sections/ComputersSection.swift | 26 ++++++------------- Resources/Localizable.xcstrings | 4 +-- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 17ff13629b16..9e244394a0b9 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -162,26 +162,16 @@ public struct ComputersSection: View { String(localized: "settings.computers.showPairingCode", defaultValue: "Pair This Mac"), subtitle: String( localized: "settings.computers.showPairingCode.subtitle", - defaultValue: "Copy a pairing link to paste on another Mac, or show a QR code for an iPhone." - ), - controlWidth: 320 + defaultValue: "Copy a pairing link and paste it into “Add by Pairing Link” on another Mac." + ) ) { - HStack(spacing: 8) { - Button(String(localized: "settings.computers.copyLink.button", defaultValue: "Copy Pairing Link")) { - copyPairingLink() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(isCopyingLink) - .accessibilityIdentifier("SettingsComputersCopyPairingLinkButton") - - Button(String(localized: "settings.computers.showPairingCode.button", defaultValue: "Show Pairing Code…")) { - hostActions.openMobilePairingWindow() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsComputersShowPairingCodeButton") + Button(String(localized: "settings.computers.copyLink.button", defaultValue: "Copy Pairing Link")) { + copyPairingLink() } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .disabled(isCopyingLink) + .accessibilityIdentifier("SettingsComputersCopyPairingLinkButton") } } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e813729a3dc5..b1ee9045b872 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -235880,13 +235880,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Copy a pairing link to paste on another Mac, or show a QR code for an iPhone." + "value": "Copy a pairing link and paste it into “Add by Pairing Link” on another Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "別の Mac に貼り付けるペアリングリンクをコピーするか、iPhone 用の QR コードを表示します。" + "value": "ペアリングリンクをコピーして、別の Mac の「ペアリングリンクで追加」に貼り付けてください。" } } } From e14482137712fcdb78cb82a97811e6ea14fed621 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 20:56:14 -0700 Subject: [PATCH 09/97] Computers: bordered style for Copy Pairing Link Match the section's other controls (Refresh, Pair, Unpair) instead of a prominent accent button. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../Sources/CmuxSettingsUI/Sections/ComputersSection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 9e244394a0b9..4e101d556f40 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -168,7 +168,7 @@ public struct ComputersSection: View { Button(String(localized: "settings.computers.copyLink.button", defaultValue: "Copy Pairing Link")) { copyPairingLink() } - .buttonStyle(.borderedProminent) + .buttonStyle(.bordered) .controlSize(.small) .disabled(isCopyingLink) .accessibilityIdentifier("SettingsComputersCopyPairingLinkButton") From d2349b5c48aa9b6c70182564f70fa20998012152 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 18:18:55 -0700 Subject: [PATCH 10/97] =?UTF-8?q?hive=20M1:=20remote-Mac=20viewer=20?= =?UTF-8?q?=E2=80=94=20live=20workspaces,=20terminal,=20and=20input=20over?= =?UTF-8?q?=20Tailscale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open a paired Mac from Settings > Computers into a viewer window: its workspace list live in the sidebar, the selected terminal rendered from the host's render-grid stream, and full keyboard control routed to the remote PTY. Disconnects recover in place with bounded backoff. - CmuxHive domain: HiveSyncRuntime (macOS MobileSyncRuntime over the shared CmxNetworkByteTransportFactory, composed like the iOS root), HiveRemoteMacSession (route dialing, workspace.list, workspace.updated push refresh, reconnect), HiveRemoteTerminalSession (subscribe + replay + delta application + terminal.input via the shared TerminalKeyEncoder), HiveTerminalGridModel (pure full/delta frame reducer mirroring MobileTerminalRenderGridReplay semantics). - New Packages/macOS/CmuxHiveUI: Canvas grid renderer (styles, colors, cursor shapes, wide glyphs, auto-fit font), AppKit key capture over a pure key-mapping table (Ctrl combos, special keys, Cmd+V paste; Command chords stay with the app), viewer split view with connection-state overlays. - App target: HiveViewerWindowController (one aux window per viewed Mac), makeViewerSession on HiveComputersService, openComputerViewer host action, Open button on paired Computers rows. The auxiliary window identifier roster moved to CmuxAuxiliaryWindows.swift (cmuxApp.swift is at its file-length hard cap). - Tests: grid reducer (6), scripted-host end-to-end sessions including the kill-connection -> re-subscribe -> re-replay recovery path (3), key mapping (5). EN+JA localization for all new strings. Closes #8001 Co-Authored-By: Claude Fable 5 --- Packages/macOS/CmuxHive/Package.swift | 4 + .../CmuxHive/HiveReconnectBackoff.swift | 17 +- .../CmuxHive/HiveRemoteMacSession.swift | 238 ++++++++++++++++ .../CmuxHive/HiveRemoteTerminalSession.swift | 186 +++++++++++++ .../CmuxHive/HiveRemoteWorkspace.swift | 42 +++ .../Sources/CmuxHive/HiveSyncRuntime.swift | 86 ++++++ .../CmuxHive/HiveTerminalGridModel.swift | 138 ++++++++++ .../HiveRemoteSessionTests.swift | 257 ++++++++++++++++++ .../HiveTerminalGridModelTests.swift | 133 +++++++++ .../CmuxHiveTests/ScriptedHostTransport.swift | 120 ++++++++ Packages/macOS/CmuxHiveUI/Package.swift | 50 ++++ .../CmuxHiveUI/HiveRemoteTerminalPane.swift | 53 ++++ .../CmuxHiveUI/HiveTerminalColor.swift | 25 ++ .../CmuxHiveUI/HiveTerminalGridView.swift | 179 ++++++++++++ .../CmuxHiveUI/HiveTerminalInputView.swift | 95 +++++++ .../CmuxHiveUI/HiveTerminalKeyMapping.swift | 84 ++++++ .../CmuxHiveUI/HiveViewerRootView.swift | 120 ++++++++ .../CmuxHiveUI/HiveViewerWorkspaceList.swift | 44 +++ .../HiveTerminalKeyMappingTests.swift | 63 +++++ .../Environment/SettingsHostActions.swift | 7 + .../Sections/ComputersSection.swift | 11 +- Resources/Localizable.xcstrings | 170 ++++++++++++ Sources/CmuxAuxiliaryWindows.swift | 52 ++++ Sources/Hive/HiveComputersService.swift | 25 ++ Sources/Hive/HiveViewerWindowController.swift | 82 ++++++ Sources/HostSettingsActions+Computers.swift | 4 + .../MobilePairingWindowController.swift | 2 +- Sources/cmuxApp.swift | 42 --- cmux.xcodeproj/project.pbxproj | 24 ++ cmux.xcworkspace/contents.xcworkspacedata | 3 + 30 files changed, 2299 insertions(+), 57 deletions(-) create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift create mode 100644 Packages/macOS/CmuxHiveUI/Package.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalColor.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalKeyMapping.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerWorkspaceList.swift create mode 100644 Packages/macOS/CmuxHiveUI/Tests/CmuxHiveUITests/HiveTerminalKeyMappingTests.swift create mode 100644 Sources/CmuxAuxiliaryWindows.swift create mode 100644 Sources/Hive/HiveViewerWindowController.swift diff --git a/Packages/macOS/CmuxHive/Package.swift b/Packages/macOS/CmuxHive/Package.swift index 6cdd2b833022..5bf5b792ca53 100644 --- a/Packages/macOS/CmuxHive/Package.swift +++ b/Packages/macOS/CmuxHive/Package.swift @@ -19,6 +19,8 @@ let package = Package( .package(path: "../../iOS/CmuxMobileRPC"), .package(path: "../../iOS/CmuxMobileShell"), .package(path: "../../iOS/CmuxMobileShellModel"), + .package(path: "../../iOS/CmuxMobileTerminalKit"), + .package(path: "../../iOS/CmuxMobileTransport"), ], targets: [ .target( @@ -29,6 +31,8 @@ let package = Package( "CmuxMobileRPC", "CmuxMobileShell", "CmuxMobileShellModel", + "CmuxMobileTerminalKit", + "CmuxMobileTransport", ], swiftSettings: [ .swiftLanguageMode(.v6), diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift index 6fdfcd36d48e..4fee13b3acd1 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift @@ -1,22 +1,13 @@ import Foundation -/// Bounded exponential reconnect backoff for hive sessions: 1s doubling to a -/// configurable cap. +/// Shared bounded reconnect backoff for hive sessions: 1s doubling to 30s. /// /// A genuine, cancellable delay (not a poll): each awaited attempt is a real /// reconnect, and cancelling the owning task cancels the pending sleep. -public struct HiveReconnectBackoff: Sendable { - /// The longest single delay, in seconds. - public var maximumSeconds: Double - - /// Creates a backoff capped at `maximumSeconds`. - public init(maximumSeconds: Double = 30) { - self.maximumSeconds = maximumSeconds - } - +public enum HiveReconnectBackoff { /// Await the backoff for the given consecutive-failure attempt count. - public func delay(attempt: Int) async { - let seconds = min(maximumSeconds, pow(2.0, Double(min(max(attempt - 1, 0), 6)))) + public static func delay(attempt: Int) async { + let seconds = min(30.0, pow(2.0, Double(min(max(attempt - 1, 0), 5)))) // Bounded, cancellable delay — the intended behavior, per the // Clock.sleep carve-out. try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift new file mode 100644 index 000000000000..91a37c89d7a0 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -0,0 +1,238 @@ +public import CMUXMobileCore +public import CmuxMobileRPC +import Foundation +public import Observation + +/// A live viewing session onto one paired Mac: the persistent RPC client, +/// the remote workspace list, and reconnect recovery. +/// +/// The macOS counterpart of the iOS shell's per-Mac connection: it dials the +/// paired Mac's routes in priority order over the shared +/// ``MobileCoreRPCClient`` (multiplexed, lazily reconnecting transport), +/// fetches `workspace.list`, and keeps the list fresh from `workspace.updated` +/// push events. When the event stream dies (network blip, host restart) the +/// session re-subscribes with a bounded backoff — the client reconnects the +/// transport on the next request — so recovery needs no relaunch. +@MainActor +@Observable +public final class HiveRemoteMacSession { + /// The session's connection lifecycle state. + public enum Phase: Equatable, Sendable { + /// No connection attempt yet. + case idle + /// Dialing routes / fetching the initial workspace list. + case connecting + /// Connected; the workspace list is live. + case connected + /// Connection lost; the session is retrying in the background. + case reconnecting + /// All routes failed; `message` is the last error's description. + case failed(message: String) + } + + /// The paired Mac's stable device id. + public let macDeviceID: String + /// The paired Mac's display name (for the window title). + public let displayName: String + + /// Connection lifecycle state. + public private(set) var phase: Phase = .idle + /// The remote workspace list, in host order. + public private(set) var workspaces: [HiveRemoteWorkspace] = [] + + @ObservationIgnored private let runtime: any MobileSyncRuntime + @ObservationIgnored private let routes: [CmxAttachRoute] + @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void + /// The connected RPC client terminal sessions share, `nil` until the + /// first successful connect. + @ObservationIgnored public private(set) var client: MobileCoreRPCClient? + @ObservationIgnored private var eventTask: Task? + @ObservationIgnored private var connectTask: Task? + + /// Creates a session onto one paired Mac. + /// + /// - Parameters: + /// - runtime: The shared sync runtime (transport factory + Stack tokens). + /// - macDeviceID: The Mac's stable device id. + /// - displayName: The Mac's display name. + /// - routes: The paired record's attach routes. + /// - retryDelay: Awaited between reconnect attempts with the + /// consecutive-failure count; production passes a bounded backoff + /// sleep, tests a recorder that returns immediately. + public init( + runtime: any MobileSyncRuntime, + macDeviceID: String, + displayName: String, + routes: [CmxAttachRoute], + retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void + ) { + self.runtime = runtime + self.macDeviceID = macDeviceID + self.displayName = displayName + self.routes = routes + self.retryDelay = retryDelay + } + + /// Start (or restart) the session: dial routes, fetch the workspace list, + /// and begin observing workspace updates. Idempotent while a connect is + /// already in flight. + public func connect() { + guard connectTask == nil else { return } + phase = .connecting + connectTask = Task { [weak self] in + await self?.runConnect() + self?.connectTask = nil + } + } + + /// Tear down the session (window closed). + public func disconnect() async { + connectTask?.cancel() + connectTask = nil + eventTask?.cancel() + eventTask = nil + if let client { + await client.disconnect() + } + client = nil + phase = .idle + } + + /// Re-fetch the workspace list from the connected Mac. + public func refreshWorkspaces() async { + guard let client else { return } + do { + workspaces = try await Self.fetchWorkspaces(client: client) + } catch { + // Keep the stale list; the event loop's stream death drives the + // visible reconnect state. + } + } + + // MARK: - Connect + + private func runConnect() async { + let supported = Set(runtime.supportedRouteKinds) + let candidates = routes + .filter { supported.contains($0.kind) } + .sorted { $0.priority < $1.priority } + guard !candidates.isEmpty else { + phase = .failed(message: Self.noRouteMessage) + return + } + var lastError: (any Error)? + for route in candidates { + if Task.isCancelled { return } + guard let ticket = Self.viewerTicket( + macDeviceID: macDeviceID, + displayName: displayName, + route: route + ) else { continue } + let candidate = MobileCoreRPCClient( + runtime: runtime, + route: route, + ticket: ticket, + allowsStackAuthFallback: true + ) + do { + let workspaces = try await Self.fetchWorkspaces(client: candidate) + if let previous = client { await previous.disconnect() } + client = candidate + self.workspaces = workspaces + phase = .connected + startEventLoop(client: candidate) + return + } catch { + lastError = error + await candidate.disconnect() + } + } + phase = .failed(message: (lastError as? MobileShellConnectionError)?.localizedDescription + ?? lastError.map(String.init(describing:)) + ?? Self.noRouteMessage) + } + + /// A route-carrier ticket for the viewer. It authorizes nothing (no + /// attach token — Stack auth is the host's sole gate); the non-empty + /// workspace id only namespaces it, mirroring the iOS manual-host flow. + static func viewerTicket( + macDeviceID: String, + displayName: String, + route: CmxAttachRoute + ) -> CmxAttachTicket? { + try? CmxAttachTicket( + workspaceID: "hive-viewer", + terminalID: nil, + macDeviceID: macDeviceID, + macDisplayName: displayName, + routes: [route] + ) + } + + private static func fetchWorkspaces(client: MobileCoreRPCClient) async throws -> [HiveRemoteWorkspace] { + let request = try MobileCoreRPCClient.requestData(method: "mobile.workspace.list") + let data = try await client.sendRequest(request) + let response = try MobileSyncWorkspaceListResponse.decode(data) + return response.workspaces.map { workspace in + HiveRemoteWorkspace( + id: workspace.id, + title: workspace.title, + isSelected: workspace.isSelected, + terminals: workspace.terminals.map { + HiveRemoteWorkspace.Terminal(id: $0.id, title: $0.title, isFocused: $0.isFocused) + } + ) + } + } + + // MARK: - Events + + private func startEventLoop(client: MobileCoreRPCClient) { + eventTask?.cancel() + eventTask = Task { [weak self] in + await self?.runEventLoop(client: client) + } + } + + private func runEventLoop(client: MobileCoreRPCClient) async { + var consecutiveFailures = 0 + while !Task.isCancelled { + let stream = await client.subscribe(to: ["workspace.updated"]) + do { + // Register the subscription host-side; this also reconnects a + // torn-down transport, which is the recovery path after a blip. + let subscribe = try MobileCoreRPCClient.requestData( + method: "mobile.events.subscribe", + params: ["topics": ["workspace.updated"]] + ) + _ = try await client.sendRequest(subscribe) + consecutiveFailures = 0 + phase = .connected + await refreshWorkspaces() + } catch is CancellationError { + return + } catch { + consecutiveFailures += 1 + if Task.isCancelled { return } + phase = .reconnecting + await retryDelay(consecutiveFailures) + continue + } + for await _ in stream { + await refreshWorkspaces() + } + // Stream finished: the transport died. Loop to re-subscribe. + if Task.isCancelled { return } + phase = .reconnecting + consecutiveFailures += 1 + await retryDelay(consecutiveFailures) + } + } + + private static var noRouteMessage: String { + String( + localized: "hive.viewer.error.noRoute", + defaultValue: "This computer hasn't advertised a reachable address. Make sure Tailscale is running on both Macs." + ) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift new file mode 100644 index 000000000000..f878643947da --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -0,0 +1,186 @@ +internal import CMUXMobileCore +public import CmuxMobileRPC +public import CmuxMobileTerminalKit +import Foundation +public import Observation + +/// A live view onto one remote terminal: the render-grid stream reduced into +/// a drawable ``HiveTerminalGridModel``, plus the keyboard input path. +/// +/// Attach performs a `mobile.terminal.replay` (full grid snapshot), then +/// consumes `terminal.render_grid` push events filtered to this surface. +/// When the event stream dies the loop re-subscribes and re-replays with a +/// bounded backoff — the shared RPC client reconnects its transport on the +/// next request — so a network blip or host restart recovers in place. +@MainActor +@Observable +public final class HiveRemoteTerminalSession { + /// The attach lifecycle state. + public enum Phase: Equatable, Sendable { + /// Not attached yet. + case idle + /// Replay requested; waiting for the first full frame. + case attaching + /// Live: frames are streaming. + case live + /// Stream lost; re-attaching in the background. + case reattaching + } + + /// The host workspace id this terminal belongs to. + public let workspaceID: String + /// The host surface id this session views. + public let terminalID: String + + /// Attach lifecycle state. + public private(set) var phase: Phase = .idle + /// The reduced grid the view renders. + public private(set) var grid = HiveTerminalGridModel() + + @ObservationIgnored private let client: MobileCoreRPCClient + @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void + @ObservationIgnored private var attachTask: Task? + + /// Creates a terminal view session over an already-connected client. + /// + /// - Parameters: + /// - client: The Mac session's shared RPC client. + /// - workspaceID: The host workspace id. + /// - terminalID: The host surface id to view. + /// - retryDelay: Awaited between re-attach attempts with the + /// consecutive-failure count (bounded backoff in production, + /// immediate in tests). + public init( + client: MobileCoreRPCClient, + workspaceID: String, + terminalID: String, + retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void + ) { + self.client = client + self.workspaceID = workspaceID + self.terminalID = terminalID + self.retryDelay = retryDelay + } + + /// Start the attach/replay/event loop. Idempotent while running. + public func attach() { + guard attachTask == nil else { return } + phase = .attaching + attachTask = Task { [weak self] in + await self?.runAttachLoop() + } + } + + /// Stop the stream (view unmounted). + public func detach() { + attachTask?.cancel() + attachTask = nil + phase = .idle + } + + // MARK: - Input + + /// Send typed text to the remote PTY (`terminal.input`). + public func send(text: String) { + guard !text.isEmpty else { return } + sendInput(text) + } + + /// Send a special key (arrows, escape, tab, …) encoded through the shared + /// ``TerminalKeyEncoder`` byte tables. + public func send(specialKey: TerminalSpecialKey, modifiers: TerminalKeyModifier = []) { + guard let bytes = TerminalKeyEncoder.encode(specialKey: specialKey, modifiers: modifiers), + let text = String(data: bytes, encoding: .utf8) else { return } + sendInput(text) + } + + /// Send a Control-modified character (`Ctrl+C` → 0x03, …). + public func send(controlCharacter: String) { + guard let bytes = TerminalKeyEncoder.controlCharacter(for: controlCharacter), + let text = String(data: bytes, encoding: .utf8) else { return } + sendInput(text) + } + + private func sendInput(_ text: String) { + let workspaceID = workspaceID + let terminalID = terminalID + let client = client + Task { + guard let request = try? MobileCoreRPCClient.requestData( + method: "mobile.terminal.input", + params: [ + "workspace_id": workspaceID, + "surface_id": terminalID, + "text": text, + ] + ) else { return } + // Fire-and-forget: a failed keystroke surfaces through the event + // stream dying (reattach), not through per-key errors. + _ = try? await client.sendRequest(request) + } + } + + // MARK: - Attach loop + + private func runAttachLoop() async { + var consecutiveFailures = 0 + while !Task.isCancelled { + // Register the local listener BEFORE the replay so no frame + // emitted between the replay response and the subscription is lost. + let stream = await client.subscribe(to: ["terminal.render_grid"]) + do { + let subscribe = try MobileCoreRPCClient.requestData( + method: "mobile.events.subscribe", + params: ["topics": ["terminal.render_grid"]] + ) + _ = try await client.sendRequest(subscribe) + try await requestReplay() + consecutiveFailures = 0 + phase = .live + } catch is CancellationError { + return + } catch { + consecutiveFailures += 1 + if Task.isCancelled { return } + phase = .reattaching + await retryDelay(consecutiveFailures) + continue + } + for await envelope in stream { + guard let payload = envelope.payloadJSON, + let frame = Self.decodeFrame(payload), + frame.surfaceID == terminalID else { continue } + grid.apply(frame) + } + // Stream finished: transport died. Re-subscribe + re-replay. + if Task.isCancelled { return } + phase = .reattaching + consecutiveFailures += 1 + await retryDelay(consecutiveFailures) + } + } + + private func requestReplay() async throws { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.terminal.replay", + params: [ + "workspace_id": workspaceID, + "surface_id": terminalID, + ] + ) + let data = try await client.sendRequest(request) + let response = try MobileTerminalReplayResponse.decode(data) + if let frame = response.renderGrid { + grid.apply(frame) + } + } + + /// Decode one `terminal.render_grid` event payload: either the wrapped + /// `{"render_grid": …}` form or the bare frame. + static func decodeFrame(_ payload: Data) -> MobileTerminalRenderGridFrame? { + if let event = try? MobileTerminalRenderGridEvent.decode(payload), let frame = event.frame { + return frame + } + return try? JSONDecoder().decode(MobileTerminalRenderGridFrame.self, from: payload) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift new file mode 100644 index 000000000000..4b69131ac107 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift @@ -0,0 +1,42 @@ +/// One workspace on a remote Mac, as reported by `workspace.list`. +/// +/// Value snapshot for the viewer's workspace list (rows never observe stores). +public struct HiveRemoteWorkspace: Equatable, Sendable, Identifiable { + /// One terminal within the workspace. + public struct Terminal: Equatable, Sendable, Identifiable { + /// Stable terminal (surface) identifier on the host. + public let id: String + /// User-facing terminal title. + public let title: String + /// Whether the terminal holds focus on the host. + public let isFocused: Bool + + public init(id: String, title: String, isFocused: Bool) { + self.id = id + self.title = title + self.isFocused = isFocused + } + } + + /// Stable workspace identifier on the host. + public let id: String + /// User-facing workspace title. + public let title: String + /// Whether the host currently has this workspace selected. + public let isSelected: Bool + /// The workspace's terminals in host order. + public let terminals: [Terminal] + + public init(id: String, title: String, isSelected: Bool, terminals: [Terminal]) { + self.id = id + self.title = title + self.isSelected = isSelected + self.terminals = terminals + } + + /// The terminal to attach when the workspace is opened: the focused one, + /// else the first. + public var defaultTerminal: Terminal? { + terminals.first(where: \.isFocused) ?? terminals.first + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift new file mode 100644 index 000000000000..c8972d83365f --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift @@ -0,0 +1,86 @@ +public import CMUXMobileCore +public import CmuxMobileRPC +import CmuxMobileTransport +public import Foundation + +/// macOS conformance of the RPC layer's `MobileSyncRuntime`, mirroring the +/// iOS app's `CMUXMobileRuntime`: a Network.framework TCP transport factory +/// over the supported route kinds plus injected Stack-token closures. +/// +/// Constructed once at the app's composition root and shared by every +/// remote-Mac session. +public struct HiveSyncRuntime: MobileSyncRuntime, Sendable { + /// Default per-request RPC deadline (matches the iOS runtime). + public static let defaultRPCRequestTimeoutNanoseconds: UInt64 = 30 * 1_000_000_000 + + public var supportedRouteKinds: [CmxAttachTransportKind] + public var transportFactory: any CmxByteTransportFactory + public var stackAccessTokenProvider: @Sendable () async throws -> String + public var stackAccessTokenForStatusProvider: @Sendable () async -> String? + public var stackAccessTokenForceRefresher: @Sendable () async throws -> String + public var rpcRequestTimeoutNanoseconds: UInt64 + public var pairingRequestTimeoutNanoseconds: UInt64 + public var now: @Sendable () -> Date + public var supportsServerPushEvents: Bool + + /// Creates a runtime over an explicit transport factory (tests inject a + /// scripted one). + public init( + supportedRouteKinds: [CmxAttachTransportKind], + transportFactory: any CmxByteTransportFactory, + stackAccessTokenProvider: @escaping @Sendable () async throws -> String, + stackAccessTokenForStatusProvider: @escaping @Sendable () async -> String? = { nil }, + stackAccessTokenForceRefresher: @escaping @Sendable () async throws -> String, + rpcRequestTimeoutNanoseconds: UInt64 = Self.defaultRPCRequestTimeoutNanoseconds, + pairingRequestTimeoutNanoseconds: UInt64 = 8 * 1_000_000_000, + now: @escaping @Sendable () -> Date = Date.init, + supportsServerPushEvents: Bool = true + ) { + self.supportedRouteKinds = supportedRouteKinds + self.transportFactory = transportFactory + self.stackAccessTokenProvider = stackAccessTokenProvider + self.stackAccessTokenForStatusProvider = stackAccessTokenForStatusProvider + self.stackAccessTokenForceRefresher = stackAccessTokenForceRefresher + self.rpcRequestTimeoutNanoseconds = rpcRequestTimeoutNanoseconds + self.pairingRequestTimeoutNanoseconds = pairingRequestTimeoutNanoseconds + self.now = now + self.supportsServerPushEvents = supportsServerPushEvents + } + + /// Builds the production runtime over the Network.framework TCP transport, + /// composed exactly like the iOS app's root: one network factory + /// registered per supported route kind. + /// + /// - Parameters: + /// - allowsLoopbackRoutes: Include `.debugLoopback` (dev builds pairing + /// two instances on one machine). + /// - stackAccessTokenProvider: Mints the Stack access token. + /// - stackAccessTokenForceRefresher: Force-mints a fresh token after an + /// auth rejection. + public static func network( + allowsLoopbackRoutes: Bool, + stackAccessTokenProvider: @escaping @Sendable () async throws -> String, + stackAccessTokenForceRefresher: @escaping @Sendable () async throws -> String + ) -> HiveSyncRuntime { + let supportedKinds: [CmxAttachTransportKind] = allowsLoopbackRoutes + ? [.debugLoopback, .tailscale] + : [.tailscale] + let networkFactory = CmxNetworkByteTransportFactory(supportedKinds: supportedKinds) + let registrations = supportedKinds.map { + CmxRouteTransportFactoryRegistration(kind: $0, factory: networkFactory) + } + let transportFactory: any CmxByteTransportFactory + do { + transportFactory = try CmxRouteTransportFactory(registrations) + } catch { + // Unreachable: the registrations are one per distinct kind. + transportFactory = networkFactory + } + return HiveSyncRuntime( + supportedRouteKinds: supportedKinds, + transportFactory: transportFactory, + stackAccessTokenProvider: stackAccessTokenProvider, + stackAccessTokenForceRefresher: stackAccessTokenForceRefresher + ) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift new file mode 100644 index 000000000000..42f218781b94 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift @@ -0,0 +1,138 @@ +public import CMUXMobileCore +import Foundation + +/// Client-side reduction of a remote terminal's render-grid stream into a +/// drawable snapshot. +/// +/// The host emits ``MobileTerminalRenderGridFrame`` values: a **full** frame is +/// a complete viewport snapshot; a **delta** clears `clearedRows` plus every +/// row that has spans, then repaints those rows' spans at absolute row +/// indexes. This model applies exactly those semantics (mirroring what +/// `MobileTerminalRenderGridReplay` synthesizes as VT bytes) directly onto a +/// row/span value model that a SwiftUI view renders. +/// +/// Pure value type: the reduction is synchronous and deterministic, so frame +/// application is unit-testable without any transport or view. +public struct HiveTerminalGridModel: Equatable, Sendable { + /// One styled run of text within a row. + public struct Span: Equatable, Sendable { + /// Zero-based start column. + public var column: Int + /// The resolved style for the run. + public var style: MobileTerminalRenderGridFrame.Style + /// The run's text. + public var text: String + /// Terminal cells occupied by each character (2 for wide glyphs). + public var cellWidth: Int + + public init( + column: Int, + style: MobileTerminalRenderGridFrame.Style, + text: String, + cellWidth: Int = 1 + ) { + self.column = column + self.style = style + self.text = text + self.cellWidth = cellWidth + } + } + + /// Grid width in columns. + public private(set) var columns: Int = 0 + /// Grid height in rows. + public private(set) var rows: Int = 0 + /// Rows' styled spans, indexed by absolute row (0.. 0, frame.rows > 0, + frame.columns != columns || frame.rows != rows { + resize(columns: frame.columns, rows: frame.rows) + } + for row in Set(frame.clearedRows).union(frame.rowSpans.map(\.row)) { + guard rowSpans.indices.contains(row) else { continue } + rowSpans[row] = [] + } + } + for span in frame.rowSpans { + guard rowSpans.indices.contains(span.row), !span.text.isEmpty else { continue } + rowSpans[span.row].append( + Span( + column: span.column, + style: stylesByID[span.styleID] ?? .default, + text: span.text, + cellWidth: max(span.cellWidth ?? 1, 1) + ) + ) + } + for row in Set(frame.rowSpans.map(\.row)) where rowSpans.indices.contains(row) { + rowSpans[row].sort { $0.column < $1.column } + } + if frame.full || frame.cursor != nil { + cursor = frame.cursor + } + if let foreground = frame.terminalForeground { terminalForeground = foreground } + if let background = frame.terminalBackground { terminalBackground = background } + if let cursorColor = frame.terminalCursorColor { terminalCursorColor = cursorColor } + stateSeq = frame.stateSeq + } + + /// The plain text of one row (spans joined with gap-filling spaces), for + /// tests and accessibility. + public func plainRow(_ row: Int) -> String { + guard rowSpans.indices.contains(row) else { return "" } + var text = "" + var column = 0 + for span in rowSpans[row] { + if span.column > column { + text += String(repeating: " ", count: span.column - column) + column = span.column + } + text += span.text + column += span.text.count * span.cellWidth + } + return text + } + + private mutating func resize(columns newColumns: Int, rows newRows: Int) { + columns = newColumns + if newRows < rows { + rowSpans = Array(rowSpans.prefix(newRows)) + } else if newRows > rows { + rowSpans.append(contentsOf: Array(repeating: [], count: newRows - rows)) + } + rows = newRows + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift new file mode 100644 index 000000000000..2b616996574c --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift @@ -0,0 +1,257 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation +import Testing +@testable import CmuxHive + +/// A scripted `MobileSyncRuntime` over the fake host transport. +private func makeRuntime(transport: ScriptedHostTransport) -> HiveSyncRuntime { + HiveSyncRuntime( + supportedRouteKinds: [.tailscale, .debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) +} + +private func tailscaleRoute() throws -> CmxAttachRoute { + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: "100.64.0.9", port: 8000), + priority: 10 + ) +} + +private func workspaceListResult() -> [String: Any] { + [ + "workspaces": [ + [ + "id": "ws-1", + "title": "repo", + "is_selected": true, + "terminals": [ + ["id": "term-1", "title": "zsh", "is_focused": true], + ["id": "term-2", "title": "logs", "is_focused": false], + ], + ], + [ + "id": "ws-2", + "title": "notes", + "is_selected": false, + "terminals": [], + ], + ] + ] +} + +/// A full render-grid frame pre-encoded to a JSON string so `@Sendable` +/// handler closures can capture it (`[String: Any]` is not Sendable). +private func fullFrameJSONString(text: String, stateSeq: UInt64) throws -> String { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: "term-1", + stateSeq: stateSeq, + columns: 20, + rows: 5, + full: true, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: text)] + ) + let data = try JSONSerialization.data(withJSONObject: frame.jsonObject()) + return String(decoding: data, as: UTF8.self) +} + +/// Decode a pre-encoded JSON object string back into a dictionary inside the +/// handler closure. +private func jsonObject(_ string: String) -> [String: Any] { + (try? JSONSerialization.jsonObject(with: Data(string.utf8)) as? [String: Any]) ?? [:] +} + +@Suite struct HiveRemoteSessionTests { + @MainActor + @Test func connectFetchesWorkspacesAndTracksUpdates() async throws { + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": + return workspaceListResult() + case "mobile.events.subscribe": + return ["stream_id": "s", "topics": ["workspace.updated"], "already_subscribed": false] + default: + return [:] + } + } + let session = HiveRemoteMacSession( + runtime: makeRuntime(transport: transport), + macDeviceID: "mac-b", + displayName: "Studio", + routes: [try tailscaleRoute()], + retryDelay: { _ in } + ) + session.connect() + await transport.waitForMethod("mobile.events.subscribe") + // The event loop refreshes once after subscribing. + await transport.waitForMethod("mobile.workspace.list", count: 2) + try await waitUntil { session.phase == .connected && session.workspaces.count == 2 } + + #expect(session.workspaces.first?.id == "ws-1") + #expect(session.workspaces.first?.terminals.count == 2) + #expect(session.workspaces.first?.defaultTerminal?.id == "term-1") + + // A workspace.updated push triggers a list refresh. + await transport.pushEvent(topic: "workspace.updated", payload: [:]) + await transport.waitForMethod("mobile.workspace.list", count: 3) + + await session.disconnect() + } + + @MainActor + @Test func terminalSessionRepliesReplayThenAppliesEventsAndSendsInput() async throws { + let replayFrame = try fullFrameJSONString(text: "hello from mac b", stateSeq: 1) + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": + return workspaceListResult() + case "mobile.events.subscribe": + return ["stream_id": "s", "topics": ["terminal.render_grid"], "already_subscribed": false] + case "mobile.terminal.replay": + return [ + "workspace_id": "ws-1", + "surface_id": "term-1", + "seq": 1, + "columns": 20, + "rows": 5, + "render_grid": jsonObject(replayFrame), + ] + case "mobile.terminal.input": + return ["workspace_id": "ws-1", "surface_id": "term-1", "queued": false] + default: + return [:] + } + } + let runtime = makeRuntime(transport: transport) + let macSession = HiveRemoteMacSession( + runtime: runtime, + macDeviceID: "mac-b", + displayName: "Studio", + routes: [try tailscaleRoute()], + retryDelay: { _ in } + ) + macSession.connect() + try await waitUntil { macSession.phase == .connected } + let client = try #require(macSession.client) + + let terminal = HiveRemoteTerminalSession( + client: client, + workspaceID: "ws-1", + terminalID: "term-1", + retryDelay: { _ in } + ) + terminal.attach() + try await waitUntil { terminal.phase == .live && terminal.grid.hasContent } + #expect(terminal.grid.plainRow(0) == "hello from mac b") + + // A delta push for this surface updates the grid; another surface's + // frame is ignored. + let delta = try MobileTerminalRenderGridFrame( + surfaceID: "term-1", + stateSeq: 2, + columns: 20, + rows: 5, + full: false, + rowSpans: [.init(row: 1, column: 0, styleID: 0, text: "second line")] + ) + await transport.pushEvent(topic: "terminal.render_grid", payload: try delta.jsonObject()) + let foreign = try MobileTerminalRenderGridFrame( + surfaceID: "term-9", + stateSeq: 9, + columns: 20, + rows: 5, + full: false, + rowSpans: [.init(row: 2, column: 0, styleID: 0, text: "not ours")] + ) + await transport.pushEvent(topic: "terminal.render_grid", payload: try foreign.jsonObject()) + try await waitUntil { terminal.grid.plainRow(1) == "second line" } + #expect(terminal.grid.plainRow(2) == "") + + // Typing reaches the host's PTY via terminal.input. + terminal.send(text: "ls\r") + await transport.waitForMethod("mobile.terminal.input") + let inputs = await transport.sentInputTexts + #expect(inputs == ["ls\r"]) + + terminal.detach() + await macSession.disconnect() + } + + @MainActor + @Test func terminalSessionReattachesAfterConnectionDrop() async throws { + let replayFrame = try fullFrameJSONString(text: "first attach", stateSeq: 1) + let secondFrame = try fullFrameJSONString(text: "after recovery", stateSeq: 5) + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": + return workspaceListResult() + case "mobile.events.subscribe": + return ["stream_id": "s", "topics": ["terminal.render_grid"], "already_subscribed": false] + case "mobile.terminal.replay": + return [ + "workspace_id": "ws-1", + "surface_id": "term-1", + "seq": 1, + "render_grid": jsonObject(replayFrame), + ] + default: + return [:] + } + } + let runtime = makeRuntime(transport: transport) + let macSession = HiveRemoteMacSession( + runtime: runtime, + macDeviceID: "mac-b", + displayName: "Studio", + routes: [try tailscaleRoute()], + retryDelay: { _ in } + ) + macSession.connect() + try await waitUntil { macSession.phase == .connected } + let client = try #require(macSession.client) + let terminal = HiveRemoteTerminalSession( + client: client, + workspaceID: "ws-1", + terminalID: "term-1", + retryDelay: { _ in } + ) + terminal.attach() + try await waitUntil { terminal.phase == .live } + let replaysBeforeDrop = await transport.sentMethods.filter { $0 == "mobile.terminal.replay" }.count + + // Drop the connection host-side: the reader sees EOF, the client + // tears down, and the attach loop re-subscribes + re-replays over a + // freshly connected transport. + await transport.killConnection() + await transport.waitForMethod("mobile.terminal.replay", count: replaysBeforeDrop + 1) + try await waitUntil { terminal.phase == .live } + await transport.pushEvent(topic: "terminal.render_grid", payload: jsonObject(secondFrame)) + try await waitUntil { terminal.grid.plainRow(0) == "after recovery" } + + terminal.detach() + await macSession.disconnect() + } +} + +/// Await a main-actor condition with a bounded deadline, yielding between +/// checks (no fixed sleeps; the deadline only bounds a hung test). +@MainActor +func waitUntil( + timeoutNanoseconds: UInt64 = 10_000_000_000, + _ condition: @MainActor () -> Bool +) async throws { + let start = DispatchTime.now().uptimeNanoseconds + while !condition() { + if DispatchTime.now().uptimeNanoseconds - start > timeoutNanoseconds { + Issue.record("waitUntil timed out") + return + } + await Task.yield() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift new file mode 100644 index 000000000000..da054322066d --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift @@ -0,0 +1,133 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxHive + +@Suite struct HiveTerminalGridModelTests { + private func fullFrame( + columns: Int = 10, + rows: Int = 4, + stateSeq: UInt64 = 1, + rowSpans: [MobileTerminalRenderGridFrame.RowSpan]? = nil + ) throws -> MobileTerminalRenderGridFrame { + try MobileTerminalRenderGridFrame( + surfaceID: "surface-1", + stateSeq: stateSeq, + columns: columns, + rows: rows, + cursor: .init(row: 1, column: 7), + full: true, + styles: [ + .init(id: 0), + .init(id: 1, foreground: "#ff0000", bold: true), + ], + rowSpans: rowSpans ?? [ + .init(row: 0, column: 0, styleID: 0, text: "hello"), + .init(row: 1, column: 2, styleID: 1, text: "world"), + ], + terminalForeground: "#ffffff", + terminalBackground: "#000000" + ) + } + + private func deltaFrame( + columns: Int = 10, + rows: Int = 4, + stateSeq: UInt64 = 2, + clearedRows: [Int] = [], + rowSpans: [MobileTerminalRenderGridFrame.RowSpan] = [], + cursor: MobileTerminalRenderGridFrame.Cursor? = nil + ) throws -> MobileTerminalRenderGridFrame { + try MobileTerminalRenderGridFrame( + surfaceID: "surface-1", + stateSeq: stateSeq, + columns: columns, + rows: rows, + cursor: cursor, + full: false, + clearedRows: clearedRows, + styles: [.init(id: 0)], + rowSpans: rowSpans + ) + } + + @Test func fullFrameReplacesGrid() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame()) + #expect(grid.hasContent) + #expect(grid.columns == 10) + #expect(grid.rows == 4) + #expect(grid.plainRow(0) == "hello") + #expect(grid.plainRow(1) == " world") + #expect(grid.plainRow(2) == "") + #expect(grid.cursor?.row == 1) + #expect(grid.cursor?.column == 7) + #expect(grid.rowSpans[1].first?.style.bold == true) + #expect(grid.terminalBackground == "#000000") + #expect(grid.stateSeq == 1) + } + + @Test func deltaClearsAndRepaintsOnlyItsRows() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame()) + grid.apply(try deltaFrame( + clearedRows: [0], + rowSpans: [.init(row: 2, column: 0, styleID: 0, text: "third")] + )) + + // Row 0 cleared, row 1 untouched, row 2 repainted. + #expect(grid.plainRow(0) == "") + #expect(grid.plainRow(1) == " world") + #expect(grid.plainRow(2) == "third") + // A delta without a cursor leaves the previous cursor alone. + #expect(grid.cursor?.row == 1) + #expect(grid.stateSeq == 2) + } + + @Test func deltaRepaintedRowReplacesPreviousSpans() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame()) + grid.apply(try deltaFrame( + stateSeq: 3, + rowSpans: [.init(row: 1, column: 0, styleID: 0, text: "new")] + )) + // The row's old " world" spans are gone, not merged. + #expect(grid.plainRow(1) == "new") + } + + @Test func deltaBeforeAnyFullFrameIsIgnored() throws { + var grid = HiveTerminalGridModel() + grid.apply(try deltaFrame( + stateSeq: 5, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "orphan")] + )) + #expect(!grid.hasContent) + #expect(grid.rows == 0) + } + + @Test func deltaWithNewGeometryResizesGrid() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame(columns: 10, rows: 4)) + grid.apply(try deltaFrame( + columns: 12, + rows: 6, + stateSeq: 6, + rowSpans: [.init(row: 5, column: 0, styleID: 0, text: "tail")] + )) + #expect(grid.columns == 12) + #expect(grid.rows == 6) + #expect(grid.plainRow(5) == "tail") + // Existing rows survive the grow. + #expect(grid.plainRow(0) == "hello") + } + + @Test func wideGlyphSpansAdvanceByCellWidth() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame(rowSpans: [ + .init(row: 0, column: 0, styleID: 0, text: "日本", cellWidth: 2), + .init(row: 0, column: 4, styleID: 0, text: "!"), + ])) + // The wide span occupies columns 0-3; no gap spaces before "!". + #expect(grid.plainRow(0) == "日本!") + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift new file mode 100644 index 000000000000..5f041dfbd489 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift @@ -0,0 +1,120 @@ +import CMUXMobileCore +import Foundation + +/// A scripted fake of the Mac host's RPC endpoint at the byte-transport seam: +/// decodes request frames, answers by method from a handler table, and lets +/// tests push server events / kill the connection to exercise recovery. +actor ScriptedHostTransport: CmxByteTransport { + typealias Handler = @Sendable (_ method: String, _ params: [String: Any]) -> [String: Any] + + private let handler: Handler + private var inbound = Data() + private var receiveWaiters: [CheckedContinuation] = [] + private var pendingResponses: [Data] = [] + private var isClosed = false + private(set) var sentMethods: [String] = [] + private(set) var sentInputTexts: [String] = [] + private var methodWaiters: [(method: String, continuation: CheckedContinuation)] = [] + + init(handler: @escaping Handler) { + self.handler = handler + } + + func connect() async throws { + isClosed = false + } + + func receive() async throws -> Data? { + if isClosed { return nil } + if !pendingResponses.isEmpty { + return pendingResponses.removeFirst() + } + return await withCheckedContinuation { continuation in + receiveWaiters.append(continuation) + } + } + + func send(_ data: Data) async throws { + if isClosed { throw CancellationError() } + inbound.append(data) + let frames = (try? MobileSyncFrameCodec.decodeFrames(from: &inbound)) ?? [] + for frame in frames { + guard let request = try? JSONSerialization.jsonObject(with: frame) as? [String: Any], + let id = request["id"] as? String, + let method = request["method"] as? String else { continue } + let params = request["params"] as? [String: Any] ?? [:] + sentMethods.append(method) + if method == "mobile.terminal.input", let text = params["text"] as? String { + sentInputTexts.append(text) + } + resolveMethodWaiters(method: method) + let result = handler(method, params) + let envelope: [String: Any] = ["id": id, "ok": true, "result": result] + if let payload = try? JSONSerialization.data(withJSONObject: envelope), + let framed = try? MobileSyncFrameCodec.encodeFrame(payload) { + deliver(framed) + } + } + } + + func close() async { + isClosed = true + let waiters = receiveWaiters + receiveWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + /// Push one server event envelope to the client. + func pushEvent(topic: String, payload: [String: Any]) { + let envelope: [String: Any] = ["kind": "event", "topic": topic, "payload": payload] + guard let data = try? JSONSerialization.data(withJSONObject: envelope), + let framed = try? MobileSyncFrameCodec.encodeFrame(data) else { return } + deliver(framed) + } + + /// Simulate the connection dying host-side (EOF to the client's reader). + func killConnection() { + let waiters = receiveWaiters + receiveWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: nil) + } + } + + /// Suspend until the host has served `count` requests of `method`. + func waitForMethod(_ method: String, count: Int = 1) async { + while sentMethods.filter({ $0 == method }).count < count { + await withCheckedContinuation { continuation in + methodWaiters.append((method, continuation)) + } + } + } + + private func resolveMethodWaiters(method: String) { + let matching = methodWaiters.filter { $0.method == method } + methodWaiters.removeAll { $0.method == method } + for waiter in matching { + waiter.continuation.resume() + } + } + + private func deliver(_ framed: Data) { + if receiveWaiters.isEmpty { + pendingResponses.append(framed) + } else { + let waiter = receiveWaiters.removeFirst() + waiter.resume(returning: framed) + } + } +} + +/// Factory handing out one shared scripted transport for every route. +struct ScriptedHostTransportFactory: CmxByteTransportFactory { + let transport: ScriptedHostTransport + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + transport + } +} diff --git a/Packages/macOS/CmuxHiveUI/Package.swift b/Packages/macOS/CmuxHiveUI/Package.swift new file mode 100644 index 000000000000..4644f12dadb6 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Package.swift @@ -0,0 +1,50 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CmuxHiveUI", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "CmuxHiveUI", + targets: ["CmuxHiveUI"] + ), + ], + dependencies: [ + .package(path: "../CmuxHive"), + .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../../iOS/CmuxMobileTerminalKit"), + ], + targets: [ + .target( + name: "CmuxHiveUI", + dependencies: [ + "CmuxHive", + "CMUXMobileCore", + "CmuxMobileTerminalKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxHiveUITests", + dependencies: [ + "CmuxHiveUI", + "CmuxHive", + "CMUXMobileCore", + "CmuxMobileTerminalKit", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift new file mode 100644 index 000000000000..96690fb69ce4 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift @@ -0,0 +1,53 @@ +public import CmuxHive +public import SwiftUI + +/// The live remote-terminal pane: grid rendering, key capture, and the +/// attach-state overlay for one ``HiveRemoteTerminalSession``. +public struct HiveRemoteTerminalPane: View { + @Bindable private var terminal: HiveRemoteTerminalSession + + /// Creates a pane over one terminal session. + public init(terminal: HiveRemoteTerminalSession) { + self.terminal = terminal + } + + public var body: some View { + ZStack { + HiveTerminalGridView(grid: terminal.grid) + HiveTerminalInputView( + actions: HiveTerminalInputView.Actions( + sendText: { [weak terminal] in terminal?.send(text: $0) }, + sendSpecial: { [weak terminal] in terminal?.send(specialKey: $0, modifiers: $1) }, + sendControl: { [weak terminal] in terminal?.send(controlCharacter: $0) } + ), + isFocused: true + ) + statusOverlay + } + .onAppear { terminal.attach() } + .onDisappear { terminal.detach() } + } + + @ViewBuilder + private var statusOverlay: some View { + switch terminal.phase { + case .attaching where !terminal.grid.hasContent: + ProgressView(String(localized: "hive.viewer.terminal.attaching", defaultValue: "Connecting to terminal…")) + .controlSize(.small) + case .reattaching: + VStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text(String(localized: "hive.viewer.terminal.reattaching", defaultValue: "Reconnecting…")) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(10) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(12) + default: + EmptyView() + } + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalColor.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalColor.swift new file mode 100644 index 000000000000..2fa1a209d511 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalColor.swift @@ -0,0 +1,25 @@ +import SwiftUI + +/// Hex-string color parsing for the render-grid palette. +/// +/// The host emits colors as `#RRGGBB` strings (already resolved from its +/// theme), so the viewer only needs literal parsing plus sensible defaults. +enum HiveTerminalColor { + /// Default background when the host reported none. + static let fallbackBackground = Color(red: 0.09, green: 0.09, blue: 0.11) + /// Default foreground when the host reported none. + static let fallbackForeground = Color(red: 0.92, green: 0.92, blue: 0.94) + + /// Parse a `#RRGGBB` / `RRGGBB` hex string. + static func parse(_ hex: String?) -> Color? { + guard let hex else { return nil } + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { value.removeFirst() } + guard value.count == 6, let rgb = UInt32(value, radix: 16) else { return nil } + return Color( + red: Double((rgb >> 16) & 0xFF) / 255.0, + green: Double((rgb >> 8) & 0xFF) / 255.0, + blue: Double(rgb & 0xFF) / 255.0 + ) + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift new file mode 100644 index 000000000000..0574bf58eb3f --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift @@ -0,0 +1,179 @@ +public import CmuxHive +import CMUXMobileCore +public import SwiftUI + +/// Draws one ``HiveTerminalGridModel`` snapshot as a fixed-cell text grid. +/// +/// A `Canvas` places each styled span at its absolute cell rectangle (column × +/// cell width, row × line height), so alignment matches the remote terminal +/// exactly for fixed-advance text; wide glyphs draw per-character at their +/// declared cell width. The font size auto-fits the available space to the +/// remote grid's columns × rows. +public struct HiveTerminalGridView: View { + private let grid: HiveTerminalGridModel + + /// Creates a grid view over one immutable grid snapshot. + public init(grid: HiveTerminalGridModel) { + self.grid = grid + } + + public var body: some View { + GeometryReader { proxy in + let metrics = HiveTerminalGridMetrics( + columns: grid.columns, + rows: grid.rows, + available: proxy.size + ) + Canvas { context, _ in + draw(in: &context, metrics: metrics) + } + } + .background(HiveTerminalColor.parse(grid.terminalBackground) ?? HiveTerminalColor.fallbackBackground) + } + + private func draw(in context: inout GraphicsContext, metrics: HiveTerminalGridMetrics) { + guard grid.hasContent else { return } + let defaultForeground = HiveTerminalColor.parse(grid.terminalForeground) + ?? HiveTerminalColor.fallbackForeground + let defaultBackground = HiveTerminalColor.parse(grid.terminalBackground) + ?? HiveTerminalColor.fallbackBackground + drawCursorBackground(in: &context, metrics: metrics) + for row in 0.. CGRect? { + guard let cursor = grid.cursor, cursor.visible, + cursor.row >= 0, cursor.row < grid.rows, + cursor.column >= 0, cursor.column < grid.columns else { return nil } + let origin = metrics.origin(row: cursor.row, column: cursor.column) + switch cursor.style { + case .bar: + return CGRect(x: origin.x, y: origin.y, width: 2, height: metrics.lineHeight) + case .underline: + return CGRect( + x: origin.x, + y: origin.y + metrics.lineHeight - 2, + width: metrics.cellWidth, + height: 2 + ) + case .block, .blockHollow: + return CGRect(x: origin.x, y: origin.y, width: metrics.cellWidth, height: metrics.lineHeight) + } + } + + private var cursorColor: Color { + HiveTerminalColor.parse(grid.terminalCursorColor) + ?? HiveTerminalColor.parse(grid.terminalForeground) + ?? HiveTerminalColor.fallbackForeground + } + + /// Filled cursor shapes paint underneath the glyphs so the character under + /// a block cursor stays readable. + private func drawCursorBackground(in context: inout GraphicsContext, metrics: HiveTerminalGridMetrics) { + guard let rect = cursorRect(metrics: metrics), grid.cursor?.style != .blockHollow else { return } + context.fill(Path(rect), with: .color(cursorColor.opacity(0.55))) + } + + private func drawCursorOutline(in context: inout GraphicsContext, metrics: HiveTerminalGridMetrics) { + guard let rect = cursorRect(metrics: metrics), grid.cursor?.style == .blockHollow else { return } + context.stroke(Path(rect), with: .color(cursorColor), lineWidth: 1) + } +} + +/// Cell geometry: fits the remote grid's columns × rows into the available +/// size by scaling one monospaced font. +struct HiveTerminalGridMetrics { + let cellWidth: CGFloat + let lineHeight: CGFloat + let fontSize: CGFloat + + init(columns: Int, rows: Int, available: CGSize) { + let columns = max(columns, 1) + let rows = max(rows, 1) + // Measure the reference font once; monospaced advances scale linearly + // with point size, so one measurement fits any target size. + let referenceSize: CGFloat = 13 + let referenceFont = NSFont.monospacedSystemFont(ofSize: referenceSize, weight: .regular) + let referenceAdvance = ("0" as NSString).size(withAttributes: [.font: referenceFont]).width + let referenceLineHeight = NSLayoutManager().defaultLineHeight(for: referenceFont) + let widthLimited = available.width / (CGFloat(columns) * referenceAdvance / referenceSize) + let heightLimited = available.height / (CGFloat(rows) * referenceLineHeight / referenceSize) + let size = max(min(widthLimited, heightLimited, 20), 4) + fontSize = size + cellWidth = referenceAdvance * size / referenceSize + lineHeight = referenceLineHeight * size / referenceSize + } + + func origin(row: Int, column: Int) -> CGPoint { + CGPoint(x: CGFloat(column) * cellWidth, y: CGFloat(row) * lineHeight) + } + + func font(bold: Bool, italic: Bool) -> Font { + var font = Font.system(size: fontSize, design: .monospaced) + if bold { font = font.bold() } + if italic { font = font.italic() } + return font + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift new file mode 100644 index 000000000000..ead725ec672c --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift @@ -0,0 +1,95 @@ +public import AppKit +public import CmuxMobileTerminalKit +public import SwiftUI + +/// First-responder key capture for the remote terminal: an invisible AppKit +/// view that turns key presses into terminal input actions. +/// +/// AppKit is the only complete source of raw key events (SwiftUI's `onKeyPress` +/// drops modifier detail), so this mirrors how the local terminal receives +/// keys — an NSView in the responder chain — while the mapping itself stays in +/// the pure ``HiveTerminalKeyMapping`` table. +public struct HiveTerminalInputView: NSViewRepresentable { + /// Closure bundle for the capture view's actions (snapshot-boundary rule: + /// the AppKit view holds closures, never a store). + public struct Actions { + var sendText: (String) -> Void + var sendSpecial: (TerminalSpecialKey, TerminalKeyModifier) -> Void + var sendControl: (String) -> Void + + public init( + sendText: @escaping (String) -> Void, + sendSpecial: @escaping (TerminalSpecialKey, TerminalKeyModifier) -> Void, + sendControl: @escaping (String) -> Void + ) { + self.sendText = sendText + self.sendSpecial = sendSpecial + self.sendControl = sendControl + } + } + + private let actions: Actions + private let isFocused: Bool + + /// Creates the input capture layer. + /// - Parameters: + /// - actions: Where key input is routed. + /// - isFocused: When `true`, the view claims first responder. + public init(actions: Actions, isFocused: Bool) { + self.actions = actions + self.isFocused = isFocused + } + + public func makeNSView(context: Context) -> HiveTerminalKeyCaptureNSView { + let view = HiveTerminalKeyCaptureNSView() + view.actions = actions + return view + } + + public func updateNSView(_ nsView: HiveTerminalKeyCaptureNSView, context: Context) { + nsView.actions = actions + if isFocused, nsView.window?.firstResponder !== nsView { + nsView.window?.makeFirstResponder(nsView) + } + } +} + +/// The AppKit key-capture view backing ``HiveTerminalInputView``. +public final class HiveTerminalKeyCaptureNSView: NSView { + var actions: HiveTerminalInputView.Actions? + + override public var acceptsFirstResponder: Bool { true } + + override public func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + } + + override public func keyDown(with event: NSEvent) { + // Cmd+V pastes into the remote PTY; every other Command chord stays + // with the app (the mapping returns nil for Command). + if event.modifierFlags.contains(.command), + event.charactersIgnoringModifiers?.lowercased() == "v" { + if let pasted = NSPasteboard.general.string(forType: .string), !pasted.isEmpty { + actions?.sendText(pasted) + } + return + } + guard let action = HiveTerminalKeyMapping.action( + keyCode: event.keyCode, + characters: event.characters, + charactersIgnoringModifiers: event.charactersIgnoringModifiers, + modifiers: event.modifierFlags + ) else { + super.keyDown(with: event) + return + } + switch action { + case .special(let key, let modifiers): + actions?.sendSpecial(key, modifiers) + case .control(let character): + actions?.sendControl(character) + case .text(let text): + actions?.sendText(text) + } + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalKeyMapping.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalKeyMapping.swift new file mode 100644 index 000000000000..38c1c1bbc344 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalKeyMapping.swift @@ -0,0 +1,84 @@ +import AppKit +internal import CmuxMobileTerminalKit + +/// Pure NSEvent-to-terminal-input mapping for the remote terminal view. +/// +/// Translates one AppKit key event into either a special-key press (routed +/// through the shared ``TerminalKeyEncoder`` byte tables), a Control +/// character, or plain text. Pure and value-in/value-out so the mapping is +/// unit-testable without synthesizing real `NSEvent`s. +enum HiveTerminalKeyMapping { + /// The terminal-input action one key event maps to. + enum Action: Equatable { + /// A special key (arrows, escape, tab, …) with modifiers. + case special(TerminalSpecialKey, TerminalKeyModifier) + /// A Control-modified character (`Ctrl+C`, …). + case control(String) + /// Plain text to send as-is. + case text(String) + } + + /// AppKit virtual key codes for the special keys terminals encode. + private static let specialKeysByCode: [UInt16: TerminalSpecialKey] = [ + 123: .leftArrow, + 124: .rightArrow, + 125: .downArrow, + 126: .upArrow, + 53: .escape, + 48: .tab, + 115: .home, + 119: .end, + 116: .pageUp, + 121: .pageDown, + 117: .delete, + ] + + /// Map one key press to a terminal action. + /// + /// - Parameters: + /// - keyCode: The event's virtual key code. + /// - characters: The event's `characters`. + /// - charactersIgnoringModifiers: The event's base characters (used for + /// Control combinations, where `characters` is already the control + /// byte). + /// - modifiers: The event's modifier flags. + /// - Returns: The action, or `nil` when the event is not terminal input + /// (e.g. a bare modifier or a Command shortcut the app should handle). + static func action( + keyCode: UInt16, + characters: String?, + charactersIgnoringModifiers: String?, + modifiers: NSEvent.ModifierFlags + ) -> Action? { + // Command chords belong to the app (menus, shortcuts), never the + // remote PTY. + if modifiers.contains(.command) { return nil } + if let special = specialKeysByCode[keyCode] { + return .special(special, terminalModifiers(from: modifiers)) + } + switch keyCode { + case 36, 76: + // Return / keypad Enter. + return .text("\r") + case 51: + // Backspace sends DEL, the shared iOS behavior. + return .text("\u{7F}") + default: + break + } + if modifiers.contains(.control), + let base = charactersIgnoringModifiers, !base.isEmpty { + return .control(base) + } + guard let characters, !characters.isEmpty else { return nil } + return .text(characters) + } + + private static func terminalModifiers(from modifiers: NSEvent.ModifierFlags) -> TerminalKeyModifier { + var result: TerminalKeyModifier = [] + if modifiers.contains(.shift) { result.insert(.shift) } + if modifiers.contains(.control) { result.insert(.control) } + if modifiers.contains(.option) { result.insert(.alternate) } + return result + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift new file mode 100644 index 000000000000..a76204a07f2e --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift @@ -0,0 +1,120 @@ +public import CmuxHive +public import SwiftUI + +/// The remote-Mac viewer window's root: the remote workspace list on the +/// left, the selected terminal's live view on the right, with connection +/// state overlays. +public struct HiveViewerRootView: View { + @Bindable private var session: HiveRemoteMacSession + @State private var selection: HiveViewerSelection? + /// Terminal sessions per (workspace, terminal), kept while the window is + /// open so switching back to a terminal reuses its streamed grid. + @State private var terminalSessions: [HiveViewerSelection: HiveRemoteTerminalSession] = [:] + + /// Creates the viewer root over one Mac session. + public init(session: HiveRemoteMacSession) { + self.session = session + } + + public var body: some View { + NavigationSplitView { + HiveViewerWorkspaceList( + workspaces: session.workspaces, + selection: $selection + ) + .navigationSplitViewColumnWidth(min: 180, ideal: 230) + } detail: { + detail + } + .navigationTitle(session.displayName) + .onAppear { session.connect() } + .onChange(of: session.workspaces) { _, workspaces in + reconcileSelection(workspaces: workspaces) + } + .task { + // The first list arrival picks the host's selected workspace. + reconcileSelection(workspaces: session.workspaces) + } + } + + @ViewBuilder + private var detail: some View { + switch session.phase { + case .idle, .connecting: + ProgressView(String(localized: "hive.viewer.connecting", defaultValue: "Connecting…")) + case .failed(let message): + ContentUnavailableView { + Label( + String(localized: "hive.viewer.failed.title", defaultValue: "Couldn't Connect"), + systemImage: "wifi.exclamationmark" + ) + } description: { + Text(message) + } actions: { + Button(String(localized: "hive.viewer.retry", defaultValue: "Retry")) { + session.connect() + } + } + case .connected, .reconnecting: + if let selection, let terminal = terminalSession(for: selection) { + HiveRemoteTerminalPane(terminal: terminal) + .id(selection) + } else { + ContentUnavailableView { + Label( + String(localized: "hive.viewer.noTerminal.title", defaultValue: "No Terminal"), + systemImage: "terminal" + ) + } description: { + Text(String( + localized: "hive.viewer.noTerminal.description", + defaultValue: "Select a terminal in the sidebar to view it live." + )) + } + } + } + } + + private func terminalSession(for selection: HiveViewerSelection) -> HiveRemoteTerminalSession? { + if let existing = terminalSessions[selection] { return existing } + guard let client = session.client else { return nil } + let terminal = HiveRemoteTerminalSession( + client: client, + workspaceID: selection.workspaceID, + terminalID: selection.terminalID, + retryDelay: HiveReconnectBackoff.delay + ) + terminalSessions[selection] = terminal + return terminal + } + + private func reconcileSelection(workspaces: [HiveRemoteWorkspace]) { + if let selection, + workspaces.contains(where: { workspace in + workspace.id == selection.workspaceID + && workspace.terminals.contains { $0.id == selection.terminalID } + }) { + return + } + let preferred = workspaces.first(where: \.isSelected) ?? workspaces.first + guard let workspace = preferred, let terminal = workspace.defaultTerminal else { + selection = nil + return + } + selection = HiveViewerSelection(workspaceID: workspace.id, terminalID: terminal.id) + } +} + +/// One selectable terminal in the viewer sidebar. +public struct HiveViewerSelection: Hashable, Sendable { + /// The host workspace id. + public let workspaceID: String + /// The host surface id. + public let terminalID: String + + public init(workspaceID: String, terminalID: String) { + self.workspaceID = workspaceID + self.terminalID = terminalID + } +} + diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerWorkspaceList.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerWorkspaceList.swift new file mode 100644 index 000000000000..b9d55cb0108d --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerWorkspaceList.swift @@ -0,0 +1,44 @@ +public import CmuxHive +public import SwiftUI + +/// The viewer sidebar: the remote Mac's workspaces with their terminals. +/// +/// Receives immutable value snapshots plus a selection binding only +/// (snapshot-boundary rule: nothing below the `List` observes a store). +public struct HiveViewerWorkspaceList: View { + private let workspaces: [HiveRemoteWorkspace] + @Binding private var selection: HiveViewerSelection? + + /// Creates the sidebar list. + public init(workspaces: [HiveRemoteWorkspace], selection: Binding) { + self.workspaces = workspaces + _selection = selection + } + + public var body: some View { + List(selection: $selection) { + ForEach(workspaces) { workspace in + Section { + ForEach(workspace.terminals) { terminal in + Label(terminal.title, systemImage: "terminal") + .lineLimit(1) + .tag(HiveViewerSelection(workspaceID: workspace.id, terminalID: terminal.id)) + } + } header: { + Text(workspace.title) + .lineLimit(1) + } + } + } + .overlay { + if workspaces.isEmpty { + Text(String( + localized: "hive.viewer.workspaces.empty", + defaultValue: "No workspaces yet." + )) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Packages/macOS/CmuxHiveUI/Tests/CmuxHiveUITests/HiveTerminalKeyMappingTests.swift b/Packages/macOS/CmuxHiveUI/Tests/CmuxHiveUITests/HiveTerminalKeyMappingTests.swift new file mode 100644 index 000000000000..ad14024afc50 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Tests/CmuxHiveUITests/HiveTerminalKeyMappingTests.swift @@ -0,0 +1,63 @@ +import AppKit +import CmuxMobileTerminalKit +import Testing +@testable import CmuxHiveUI + +@Suite struct HiveTerminalKeyMappingTests { + @Test func arrowKeysMapToSpecialKeys() { + let action = HiveTerminalKeyMapping.action( + keyCode: 126, + characters: nil, + charactersIgnoringModifiers: nil, + modifiers: [] + ) + #expect(action == .special(.upArrow, [])) + } + + @Test func modifiersRideAlongOnSpecialKeys() { + let action = HiveTerminalKeyMapping.action( + keyCode: 123, + characters: nil, + charactersIgnoringModifiers: nil, + modifiers: [.option] + ) + #expect(action == .special(.leftArrow, [.alternate])) + let shiftTab = HiveTerminalKeyMapping.action( + keyCode: 48, + characters: nil, + charactersIgnoringModifiers: nil, + modifiers: [.shift] + ) + #expect(shiftTab == .special(.tab, [.shift])) + } + + @Test func returnAndBackspaceMapToBytes() { + #expect(HiveTerminalKeyMapping.action( + keyCode: 36, characters: "\r", charactersIgnoringModifiers: "\r", modifiers: [] + ) == .text("\r")) + #expect(HiveTerminalKeyMapping.action( + keyCode: 51, characters: "\u{7F}", charactersIgnoringModifiers: "\u{7F}", modifiers: [] + ) == .text("\u{7F}")) + } + + @Test func controlCombinationsUseBaseCharacter() { + let action = HiveTerminalKeyMapping.action( + keyCode: 8, + characters: "\u{03}", + charactersIgnoringModifiers: "c", + modifiers: [.control] + ) + #expect(action == .control("c")) + // The shared encoder turns it into the control byte. + #expect(TerminalKeyEncoder.controlCharacter(for: "c") == Data([0x03])) + } + + @Test func plainTypingPassesThroughAndCommandChordsDoNot() { + #expect(HiveTerminalKeyMapping.action( + keyCode: 0, characters: "a", charactersIgnoringModifiers: "a", modifiers: [] + ) == .text("a")) + #expect(HiveTerminalKeyMapping.action( + keyCode: 0, characters: "a", charactersIgnoringModifiers: "a", modifiers: [.command] + ) == nil) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index f05de26d13a8..559816ecf866 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -174,6 +174,10 @@ public protocol SettingsHostActions: AnyObject { /// pasting it — no camera involved. func copyComputerPairingLink() async -> ComputersCopyLinkResult + /// Opens the remote-workspace viewer window for a paired computer. The + /// host owns the window, so the package can't open it directly. + func openComputerViewer(deviceID: String) + /// Applies an explicitly-requested iOS pairing port, checking availability /// first so a port already in use leaves the running listener untouched. The /// Mobile section calls this from its **Apply** button and renders the @@ -264,6 +268,9 @@ public extension SettingsHostActions { /// Default failure for hosts without a pairing listener (previews/tests). func copyComputerPairingLink() async -> ComputersCopyLinkResult { .failed } + /// Default no-op viewer open for previews/tests. + func openComputerViewer(deviceID: String) {} + /// Default: save-for-later, for hosts without a live mobile service (previews/tests). func applyMobilePairingPort(_ port: Int) async -> MobilePairingPortApplyResult { (1...65535).contains(port) ? .savedForLater(port: port) : .invalid(requestedPort: port) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 4e101d556f40..bdd72d0fb279 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -76,7 +76,8 @@ public struct ComputersSection: View { computer: computer, isPending: pendingDeviceIDs.contains(computer.deviceID), pair: { pair(deviceID: computer.deviceID) }, - unpair: { unpair(deviceID: computer.deviceID) } + unpair: { unpair(deviceID: computer.deviceID) }, + open: { hostActions.openComputerViewer(deviceID: computer.deviceID) } ) } } @@ -282,6 +283,7 @@ private struct ComputersSectionRow: View { let isPending: Bool let pair: () -> Void let unpair: () -> Void + let open: () -> Void var body: some View { SettingsCardRow( @@ -359,6 +361,13 @@ private struct ComputersSectionRow: View { @ViewBuilder private var control: some View { if computer.isPaired, !computer.isThisMac { + Button(String(localized: "settings.computers.row.open", defaultValue: "Open")) { + open() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + .accessibilityIdentifier("SettingsComputersOpenButton-\(computer.deviceID)") + Button(String(localized: "settings.computers.row.unpair", defaultValue: "Unpair")) { unpair() } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b1ee9045b872..4af479ce0d2c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -236213,6 +236213,176 @@ } } } + }, + "hive.viewer.error.noRoute": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This computer hasn't advertised a reachable address. Make sure Tailscale is running on both Macs." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このコンピュータは接続可能なアドレスを公開していません。両方の Mac で Tailscale が実行中か確認してください。" + } + } + } + }, + "hive.viewer.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続中…" + } + } + } + }, + "hive.viewer.failed.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't Connect" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続できませんでした" + } + } + } + }, + "hive.viewer.retry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Retry" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + } + } + }, + "hive.viewer.noTerminal.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルがありません" + } + } + } + }, + "hive.viewer.noTerminal.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select a terminal in the sidebar to view it live." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーでターミナルを選択するとライブ表示できます。" + } + } + } + }, + "hive.viewer.workspaces.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No workspaces yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースはまだありません。" + } + } + } + }, + "hive.viewer.terminal.attaching": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting to terminal…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルに接続中…" + } + } + } + }, + "hive.viewer.terminal.reattaching": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reconnecting…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再接続中…" + } + } + } + }, + "settings.computers.row.open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + } + } } }, "version": "1.0" diff --git a/Sources/CmuxAuxiliaryWindows.swift b/Sources/CmuxAuxiliaryWindows.swift new file mode 100644 index 000000000000..7731c8165d49 --- /dev/null +++ b/Sources/CmuxAuxiliaryWindows.swift @@ -0,0 +1,52 @@ +import AppKit + +/// Identifiers of standalone auxiliary windows (settings, about, debug +/// windows, the pairing window, …). A window carrying one of these handles +/// the standard close shortcut itself instead of routing it through +/// workspace/panel close behavior. Extracted from `cmuxApp.swift` so the +/// roster can grow without growing that file. +private let cmuxAuxiliaryWindowIdentifiers: Set = [ + "cmux.settings", + "cmux.about", + "cmux.licenses", + "cmux.browser-popup", + "cmux.browserProfilePopoverDebug", + "cmux.configEditor", + "cmux.defaultTerminalRegistrationError", + "cmux.feedButtonStyleDebug", + "cmux.feedPreview", + "cmux.feedTextEditorDebug", + "cmux.fileExplorerStyleDebug", + "cmux.folderDragIcon", + "cmux.pdfPreviewChromeDebug", + "cmux.proBadgeDebug", + "cmux.recentlyClosedHistory", + "cmux.splitButtonLayoutDebug", + "cmux.tabBarBackdropLab", + "cmux.taskManager", + "cmux.aboutTitlebarDebug", + "cmux.debugWindowControls", + "cmux.browserImportHintDebug", + "cmux.extensionSidebarInspector", + "cmux.sidebarDebug", + "cmux.menubarDebug", + "cmux.spinnerGallery", + "cmux.backgroundDebug", + "cmux.startupAppearanceDebug", + "cmux.bonsplitTabBarDebug", + "cmux.titlebarLayoutDebug", + "cmux.devWindowDisplay", + "cmux.mobilePairingWindow", +] + +/// Returns whether the given window should handle the standard close shortcut +/// as a standalone auxiliary window instead of routing it through workspace or +/// panel-close behavior. +func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool { + guard let identifier = window?.identifier?.rawValue else { return false } + // Hive viewer windows carry a per-computer suffix, so match the prefix. + if identifier.hasPrefix(HiveViewerWindowController.windowIdentifierPrefix) { + return true + } + return cmuxAuxiliaryWindowIdentifiers.contains(identifier) +} diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index f55574700506..51c2b6bf384e 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -49,6 +49,31 @@ final class HiveComputersService { } } + /// Builds a live viewing session onto one paired computer, or `nil` when + /// the computer has no local pairing record (the Computers pane only + /// offers Open on paired rows) or auth is not configured. + func makeViewerSession(deviceID: String) async -> HiveRemoteMacSession? { + guard let auth, let directory else { return nil } + guard let computer = directory.computers.first(where: { $0.deviceID == deviceID }) else { + return nil + } + // Prefer the freshest routes: a live online instance's advertised set, + // falling back to whatever the pairing/registry row carries. + guard let best = computer.bestPairingRoutes else { return nil } + let runtime = HiveSyncRuntime.network( + allowsLoopbackRoutes: Self.allowsLoopbackPairing, + stackAccessTokenProvider: { try await auth.accessToken() }, + stackAccessTokenForceRefresher: { try await auth.forceRefreshAccessToken() } + ) + return HiveRemoteMacSession( + runtime: runtime, + macDeviceID: deviceID, + displayName: computer.displayName, + routes: best.routes, + retryDelay: HiveReconnectBackoff.delay + ) + } + /// Dev builds may pair over loopback so two instances on one machine can /// dogfood Mac-to-Mac viewing; release builds never dial themselves. private static var allowsLoopbackPairing: Bool { diff --git a/Sources/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift new file mode 100644 index 000000000000..4d486e73ec55 --- /dev/null +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -0,0 +1,82 @@ +import AppKit +import CmuxHive +import CmuxHiveUI +import SwiftUI + +/// Owns the remote-Mac viewer windows, one per viewed computer. +/// +/// Follows the auxiliary-window pattern (`ReleasingWindowController` / +/// `MobilePairingWindowController`): opening the same computer again focuses +/// its existing window instead of spawning a duplicate, and closing the +/// window tears the session down. +@MainActor +final class HiveViewerWindowController: NSObject, NSWindowDelegate { + static let shared = HiveViewerWindowController() + + /// The viewer windows' identifier prefix. The exact identifier is + /// `.`; `cmuxWindowShouldOwnCloseShortcut` + /// (CmuxAuxiliaryWindows.swift) prefix-matches it so Cmd+W closes the + /// viewer instead of a terminal tab in the main window behind it. + static let windowIdentifierPrefix = "cmux.hiveViewerWindow" + + private struct OpenViewer { + let window: NSWindow + let session: HiveRemoteMacSession + } + + private var viewersByDeviceID: [String: OpenViewer] = [:] + + private override init() { + super.init() + } + + /// Opens (or focuses) the viewer window for one paired computer. + func show(deviceID: String) { + NSApp.activate(ignoringOtherApps: true) + if let existing = viewersByDeviceID[deviceID] { + if existing.window.isMiniaturized { + existing.window.deminiaturize(nil) + } + existing.window.makeKeyAndOrderFront(nil) + return + } + Task { @MainActor in + guard let session = await HiveComputersService.shared.makeViewerSession(deviceID: deviceID) else { + return + } + presentWindow(deviceID: deviceID, session: session) + } + } + + private func presentWindow(deviceID: String, session: HiveRemoteMacSession) { + if let existing = viewersByDeviceID[deviceID] { + existing.window.makeKeyAndOrderFront(nil) + return + } + let appearanceMode = UserDefaults.standard.string(forKey: AppearanceSettings.appearanceModeKey) + let root = HiveViewerRootView(session: session) + .cmuxAppearanceColorScheme(appearanceMode) + let hostingController = NSHostingController(rootView: root) + let window = NSWindow(contentViewController: hostingController) + window.title = session.displayName + window.identifier = NSUserInterfaceItemIdentifier("\(Self.windowIdentifierPrefix).\(deviceID)") + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.setContentSize(NSSize(width: 980, height: 640)) + window.contentMinSize = NSSize(width: 560, height: 360) + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + viewersByDeviceID[deviceID] = OpenViewer(window: window, session: session) + window.makeKeyAndOrderFront(nil) + } + + func windowWillClose(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + let entry = viewersByDeviceID.first(where: { $0.value.window === window }) else { return } + viewersByDeviceID.removeValue(forKey: entry.key) + let session = entry.value.session + Task { @MainActor in + await session.disconnect() + } + } +} diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index aba7befd271b..45afc6ab68e3 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -62,6 +62,10 @@ extension HostSettingsActions { await directory.unpair(deviceID: deviceID) } + func openComputerViewer(deviceID: String) { + HiveViewerWindowController.shared.show(deviceID: deviceID) + } + /// Mints this Mac's attach link (the same payload the pairing window's QR /// encodes) and copies it to the clipboard for pasting on another Mac. /// diff --git a/Sources/Mobile/Pairing/MobilePairingWindowController.swift b/Sources/Mobile/Pairing/MobilePairingWindowController.swift index 2a9e5236604c..b5e53354eb4a 100644 --- a/Sources/Mobile/Pairing/MobilePairingWindowController.swift +++ b/Sources/Mobile/Pairing/MobilePairingWindowController.swift @@ -13,7 +13,7 @@ final class MobilePairingWindowController: ReleasingWindowController { static let shared = MobilePairingWindowController() /// The pairing window's identifier. Listed in - /// `cmuxAuxiliaryWindowIdentifiers` (cmuxApp.swift) so the standard close + /// `cmuxAuxiliaryWindowIdentifiers` (CmuxAuxiliaryWindows.swift) so the standard close /// shortcut closes this window instead of a terminal tab in the main /// window behind it; referenced from the Cmd+W regression test. static let windowIdentifier = "cmux.mobilePairingWindow" diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index bf9c4fcdf122..bb02a2c762be 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1443,48 +1443,6 @@ private struct MainWindowBootstrapView: View { } } -private let cmuxAuxiliaryWindowIdentifiers: Set = [ - "cmux.settings", - "cmux.about", - "cmux.licenses", - "cmux.browser-popup", - "cmux.browserProfilePopoverDebug", - "cmux.configEditor", - "cmux.defaultTerminalRegistrationError", - "cmux.feedButtonStyleDebug", - "cmux.feedPreview", - "cmux.feedTextEditorDebug", - "cmux.fileExplorerStyleDebug", - "cmux.folderDragIcon", - "cmux.pdfPreviewChromeDebug", - "cmux.proBadgeDebug", - "cmux.recentlyClosedHistory", - "cmux.splitButtonLayoutDebug", - "cmux.tabBarBackdropLab", - "cmux.taskManager", - "cmux.aboutTitlebarDebug", - "cmux.debugWindowControls", - "cmux.browserImportHintDebug", - "cmux.extensionSidebarInspector", - "cmux.sidebarDebug", - "cmux.menubarDebug", - "cmux.spinnerGallery", - "cmux.backgroundDebug", - "cmux.startupAppearanceDebug", - "cmux.bonsplitTabBarDebug", - "cmux.titlebarLayoutDebug", - "cmux.devWindowDisplay", - "cmux.mobilePairingWindow", -] - -/// Returns whether the given window should handle the standard close shortcut -/// as a standalone auxiliary window instead of routing it through workspace or -/// panel-close behavior. -func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool { - guard let identifier = window?.identifier?.rawValue else { return false } - return cmuxAuxiliaryWindowIdentifiers.contains(identifier) -} - private enum DebugWindowConfigSnapshot { static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) { let pasteboard = NSPasteboard.general diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 316b7d194300..1ba842418e14 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -432,6 +432,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C9A2B00000000000000000C3 /* CmuxAppKitSupportUI in Frameworks */ = {isa = PBXBuildFile; productRef = C9A2B00000000000000000C2 /* CmuxAppKitSupportUI */; }; 5EDB6027B346C46521A93C74 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; 53750003A0B1C2D3E4F50003 /* CmuxAuthRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 53750002A0B1C2D3E4F50002 /* CmuxAuthRuntime */; }; + C41FE00000000000000000C6 /* CmuxAuxiliaryWindows.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D6 /* CmuxAuxiliaryWindows.swift */; }; D7032A060000000000000001 /* CmuxBrowser in Frameworks */ = {isa = PBXBuildFile; productRef = D7032A060000000000000002 /* CmuxBrowser */; }; E3B7A30000000000000000C3 /* CmuxBrowser in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000C2 /* CmuxBrowser */; }; CA52A003CA52A003CA52A003 /* CmuxCanvas in Frameworks */ = {isa = PBXBuildFile; productRef = CA52A005CA52A005CA52A005 /* CmuxCanvas */; }; @@ -543,6 +544,8 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE34020000000000000002 /* CmuxHelpResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000004 /* CmuxHelpResource.swift */; }; C41FE00000000000000000B1 /* CmuxHive in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A2 /* CmuxHive */; }; C41FE00000000000000000B2 /* CmuxHive in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A2 /* CmuxHive */; }; + C41FE00000000000000000B3 /* CmuxHiveUI in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A4 /* CmuxHiveUI */; }; + C41FE00000000000000000B4 /* CmuxHiveUI in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A4 /* CmuxHiveUI */; }; C0DE46010000000000000001 /* CMUXInstalledExtensionSidebarHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */; }; E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */; }; 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; @@ -857,6 +860,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */; }; 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */; }; C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D2 /* HiveComputersService.swift */; }; + C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D5 /* HiveViewerWindowController.swift */; }; CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */; }; B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */; }; @@ -2345,6 +2349,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E3309A08 /* cmuxApp+EqualizeSplitsMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+EqualizeSplitsMenu.swift"; sourceTree = ""; }; C4160A010000000000000002 /* cmuxApp+HistoryMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+HistoryMenu.swift"; sourceTree = ""; }; A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; + C41FE00000000000000000D6 /* CmuxAuxiliaryWindows.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CmuxAuxiliaryWindows.swift; sourceTree = ""; }; 61AD48A9E6C3F1BE2B547FFE /* CMUXCLI+AgentHookCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookCatalog.swift"; sourceTree = ""; }; B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookDefinitions.swift"; sourceTree = ""; }; 5257257034CA4729B1211170 /* CMUXCLI+AgentHookPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookPayload.swift"; sourceTree = ""; }; @@ -2720,6 +2725,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesAgentIndex.swift; sourceTree = ""; }; 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenRightSidebarContentMountingTests.swift; sourceTree = ""; }; C41FE00000000000000000D2 /* HiveComputersService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputersService.swift; sourceTree = ""; }; + C41FE00000000000000000D5 /* HiveViewerWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveViewerWindowController.swift; sourceTree = ""; }; CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/HostedInspectorDockControlScript.swift; sourceTree = ""; }; B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedInspectorDockControlScriptTests.swift; sourceTree = ""; }; @@ -3723,6 +3729,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, C617000000000000000000A3 /* CmuxGit in Frameworks */, C41FE00000000000000000B1 /* CmuxHive in Frameworks */, + C41FE00000000000000000B3 /* CmuxHiveUI in Frameworks */, 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */, E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */, E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */, @@ -3798,6 +3805,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = CC0DE00000000000000000A4 /* CmuxCore in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, C41FE00000000000000000B2 /* CmuxHive in Frameworks */, + C41FE00000000000000000B4 /* CmuxHiveUI in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, C0DE4A000000000000000005 /* CmuxSidebarProviderKit in Frameworks */, 730600040000000000000001 /* CmuxWindowing in Frameworks */, @@ -3952,6 +3960,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = PBXGroup; children = ( C41FE00000000000000000D2 /* HiveComputersService.swift */, + C41FE00000000000000000D5 /* HiveViewerWindowController.swift */, ); name = Hive; path = Hive; @@ -4031,6 +4040,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F50030020000000000000002 /* TitlebarInteractiveControlModifier.swift */, C0DE34020000000000000003 /* CmuxHelpCommands.swift */, C0DE34020000000000000004 /* CmuxHelpResource.swift */, + C41FE00000000000000000D6 /* CmuxAuxiliaryWindows.swift */, A50019A1 /* SettingsNavigation.swift */, C41FE00000000000000000D4 /* SettingsNavigationTarget+SearchText.swift */, D5671402D5671402D5671402 /* SettingsNavigation+TerminalScrollSpeed.swift */, @@ -5769,6 +5779,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A8BD195031FC4B82B4354297 /* StackAuth */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, + C41FE00000000000000000A4 /* CmuxHiveUI */, ); productName = cmux; productReference = A5001000 /* cmux.app */; @@ -5854,6 +5865,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, C41FE00000000000000000A2 /* CmuxHive */, + C41FE00000000000000000A4 /* CmuxHiveUI */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, 5E2701040000000000000001 /* Sentry */, 5E2701040000000000000002 /* CmuxFoundation */, @@ -5931,6 +5943,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */, + C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, @@ -6377,6 +6390,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = E3309A07 /* cmuxApp+EqualizeSplitsMenu.swift in Sources */, C4160A010000000000000001 /* cmuxApp+HistoryMenu.swift in Sources */, A5001001 /* cmuxApp.swift in Sources */, + C41FE00000000000000000C6 /* CmuxAuxiliaryWindows.swift in Sources */, A72C9F4179B54DF38E99A021 /* CmuxCLIPathInstaller.swift in Sources */, A5001650 /* CmuxConfig.swift in Sources */, A5FB1209 /* CmuxConfigActionDefinition.swift in Sources */, @@ -6583,6 +6597,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC72020000000000000001 /* GPUSpinnerStyle.swift in Sources */, F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */, C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */, + C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */, C41FE00000000000000000C1 /* HostSettingsActions+Computers.swift in Sources */, @@ -8315,6 +8330,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxHive; }; + C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxHiveUI; + }; 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */ = { isa = XCLocalSwiftPackageReference; relativePath = "vendor/stack-auth-swift-sdk-prerelease"; @@ -8787,6 +8806,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = package = C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */; productName = CmuxHive; }; + C41FE00000000000000000A4 /* CmuxHiveUI */ = { + isa = XCSwiftPackageProductDependency; + package = C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */; + productName = CmuxHiveUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A5001070 /* Project object */; diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index aa9481a294d9..2a85cf4f3923 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -132,6 +132,9 @@ + + From aa4f537c5e1d7b639bdf3a025128763b7e3439b9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 18:58:46 -0700 Subject: [PATCH 11/97] Fix viewer-branch CI: repair pbxproj merge, follow aux-window lint, de-namespace backoff - The rebase's union merge dropped two closing braces in project.pbxproj (XCLocalSwiftPackageReference / XCSwiftPackageProductDependency sections), leaving the project unparseable by Xcode while still passing check-pbxproj.sh. Restore them; plutil -lint now passes. - The auxiliary-window close-shortcut lint hardcodes the identifier roster's file. The roster moved to Sources/CmuxAuxiliaryWindows.swift (cmuxApp.swift is at its length hard cap), so point the lint, its fixture harness, and the review-bot rule at the new path. Viewer windows now share one static identifier (cmux.hiveViewerWindow), listed in the roster like every other auxiliary window, instead of per-device suffixed identifiers the lint cannot see. - HiveReconnectBackoff was an all-static namespace enum (package conventions ERROR); it is now an instantiable value with a configurable cap. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../swift-auxiliary-window-close-shortcuts.md | 2 +- .../Sources/CmuxHive/HiveReconnectBackoff.swift | 17 +++++++++++++---- .../Sources/CmuxHiveUI/HiveViewerRootView.swift | 2 +- Sources/CmuxAuxiliaryWindows.swift | 5 +---- Sources/Hive/HiveComputersService.swift | 2 +- Sources/Hive/HiveViewerWindowController.swift | 12 ++++++------ .../lint_auxiliary_window_close_shortcuts.py | 2 +- .../test_ci_auxiliary_window_close_shortcuts.sh | 12 ++++++------ 8 files changed, 30 insertions(+), 24 deletions(-) diff --git a/.github/review-bot-rules/swift-auxiliary-window-close-shortcuts.md b/.github/review-bot-rules/swift-auxiliary-window-close-shortcuts.md index be0341b79b2e..8294396956f5 100644 --- a/.github/review-bot-rules/swift-auxiliary-window-close-shortcuts.md +++ b/.github/review-bot-rules/swift-auxiliary-window-close-shortcuts.md @@ -5,7 +5,7 @@ Standalone cmux-owned windows must have one close-shortcut owner so Cmd+W closes Report a failure when the diff introduces or materially changes: - A user-visible `NSWindow`, `NSPanel`, `NSWindowController`, SwiftUI `Window`, or SwiftUI `WindowGroup` without a stable `cmux.*` window identifier. -- A `cmux.*` window identifier assignment that is missing from `cmuxAuxiliaryWindowIdentifiers` in `Sources/cmuxApp.swift`. +- A `cmux.*` window identifier assignment that is missing from `cmuxAuxiliaryWindowIdentifiers` in `Sources/CmuxAuxiliaryWindows.swift`. - A new standalone debug, settings, preview, task, editor, browser, file, import, config, or inspector window that can become key but is not covered by `cmuxWindowShouldOwnCloseShortcut`. - A custom Cmd+W, `performKeyEquivalent`, or close-menu workaround that bypasses the shared `cmuxWindowShouldOwnCloseShortcut` routing instead of registering the window identifier. diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift index 4fee13b3acd1..6fdfcd36d48e 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift @@ -1,13 +1,22 @@ import Foundation -/// Shared bounded reconnect backoff for hive sessions: 1s doubling to 30s. +/// Bounded exponential reconnect backoff for hive sessions: 1s doubling to a +/// configurable cap. /// /// A genuine, cancellable delay (not a poll): each awaited attempt is a real /// reconnect, and cancelling the owning task cancels the pending sleep. -public enum HiveReconnectBackoff { +public struct HiveReconnectBackoff: Sendable { + /// The longest single delay, in seconds. + public var maximumSeconds: Double + + /// Creates a backoff capped at `maximumSeconds`. + public init(maximumSeconds: Double = 30) { + self.maximumSeconds = maximumSeconds + } + /// Await the backoff for the given consecutive-failure attempt count. - public static func delay(attempt: Int) async { - let seconds = min(30.0, pow(2.0, Double(min(max(attempt - 1, 0), 5)))) + public func delay(attempt: Int) async { + let seconds = min(maximumSeconds, pow(2.0, Double(min(max(attempt - 1, 0), 6)))) // Bounded, cancellable delay — the intended behavior, per the // Clock.sleep carve-out. try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift index a76204a07f2e..66cf49567b89 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift @@ -82,7 +82,7 @@ public struct HiveViewerRootView: View { client: client, workspaceID: selection.workspaceID, terminalID: selection.terminalID, - retryDelay: HiveReconnectBackoff.delay + retryDelay: HiveReconnectBackoff().delay(attempt:) ) terminalSessions[selection] = terminal return terminal diff --git a/Sources/CmuxAuxiliaryWindows.swift b/Sources/CmuxAuxiliaryWindows.swift index 7731c8165d49..b138b6430b75 100644 --- a/Sources/CmuxAuxiliaryWindows.swift +++ b/Sources/CmuxAuxiliaryWindows.swift @@ -37,6 +37,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.titlebarLayoutDebug", "cmux.devWindowDisplay", "cmux.mobilePairingWindow", + "cmux.hiveViewerWindow", ] /// Returns whether the given window should handle the standard close shortcut @@ -44,9 +45,5 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ /// panel-close behavior. func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool { guard let identifier = window?.identifier?.rawValue else { return false } - // Hive viewer windows carry a per-computer suffix, so match the prefix. - if identifier.hasPrefix(HiveViewerWindowController.windowIdentifierPrefix) { - return true - } return cmuxAuxiliaryWindowIdentifiers.contains(identifier) } diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 51c2b6bf384e..c0e0b5242db4 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -70,7 +70,7 @@ final class HiveComputersService { macDeviceID: deviceID, displayName: computer.displayName, routes: best.routes, - retryDelay: HiveReconnectBackoff.delay + retryDelay: HiveReconnectBackoff().delay(attempt:) ) } diff --git a/Sources/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift index 4d486e73ec55..50eb29b9dff5 100644 --- a/Sources/Hive/HiveViewerWindowController.swift +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -13,11 +13,11 @@ import SwiftUI final class HiveViewerWindowController: NSObject, NSWindowDelegate { static let shared = HiveViewerWindowController() - /// The viewer windows' identifier prefix. The exact identifier is - /// `.`; `cmuxWindowShouldOwnCloseShortcut` - /// (CmuxAuxiliaryWindows.swift) prefix-matches it so Cmd+W closes the - /// viewer instead of a terminal tab in the main window behind it. - static let windowIdentifierPrefix = "cmux.hiveViewerWindow" + /// The viewer windows' shared identifier (identifiers need not be unique + /// per window). Listed in `cmuxAuxiliaryWindowIdentifiers` + /// (CmuxAuxiliaryWindows.swift) so Cmd+W closes the viewer instead of a + /// terminal tab in the main window behind it. + static let windowIdentifier = "cmux.hiveViewerWindow" private struct OpenViewer { let window: NSWindow @@ -59,7 +59,7 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { let hostingController = NSHostingController(rootView: root) let window = NSWindow(contentViewController: hostingController) window.title = session.displayName - window.identifier = NSUserInterfaceItemIdentifier("\(Self.windowIdentifierPrefix).\(deviceID)") + window.identifier = NSUserInterfaceItemIdentifier(Self.windowIdentifier) window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.setContentSize(NSSize(width: 980, height: 640)) window.contentMinSize = NSSize(width: 560, height: 360) diff --git a/scripts/lint_auxiliary_window_close_shortcuts.py b/scripts/lint_auxiliary_window_close_shortcuts.py index 63341df69f39..34a9713a927d 100755 --- a/scripts/lint_auxiliary_window_close_shortcuts.py +++ b/scripts/lint_auxiliary_window_close_shortcuts.py @@ -10,7 +10,7 @@ DEFAULT_ROOTS = ("Sources",) -OWNER_LIST_PATH = pathlib.Path("Sources/cmuxApp.swift") +OWNER_LIST_PATH = pathlib.Path("Sources/CmuxAuxiliaryWindows.swift") OWNER_LIST_NAME = "cmuxAuxiliaryWindowIdentifiers" # Hidden/internal bootstrap windows should not take Cmd+W away from the active diff --git a/tests/test_ci_auxiliary_window_close_shortcuts.sh b/tests/test_ci_auxiliary_window_close_shortcuts.sh index 4aacd128977e..3c1e32bd1366 100755 --- a/tests/test_ci_auxiliary_window_close_shortcuts.sh +++ b/tests/test_ci_auxiliary_window_close_shortcuts.sh @@ -11,7 +11,7 @@ trap 'rm -rf "$TMP_DIR"' EXIT mkdir -p "$TMP_DIR/Sources" -cat > "$TMP_DIR/Sources/cmuxApp.swift" <<'SWIFT' +cat > "$TMP_DIR/Sources/CmuxAuxiliaryWindows.swift" <<'SWIFT' private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.settings", ] @@ -38,7 +38,7 @@ fi grep -q "cmux.newWindow" "$TMP_DIR/missing.out" grep -q "Sources/NewWindow.swift:9" "$TMP_DIR/missing.out" -cat > "$TMP_DIR/Sources/cmuxApp.swift" <<'SWIFT' +cat > "$TMP_DIR/Sources/CmuxAuxiliaryWindows.swift" <<'SWIFT' private let cmuxAuxiliaryWindowIdentifiers: Set = [ // "cmux.newWindow", /* @@ -54,7 +54,7 @@ if python3 scripts/lint_auxiliary_window_close_shortcuts.py --repo-root "$TMP_DI fi grep -q "cmux.newWindow" "$TMP_DIR/commented-owner.out" -cat > "$TMP_DIR/Sources/cmuxApp.swift" <<'SWIFT' +cat > "$TMP_DIR/Sources/CmuxAuxiliaryWindows.swift" <<'SWIFT' private let cmuxAuxiliaryWindowIdentifiers: Set = [ // MARK: - Main Windows [user-closable] // This comment intentionally contains a lone ] bracket. @@ -80,7 +80,7 @@ SWIFT python3 scripts/lint_auxiliary_window_close_shortcuts.py --repo-root "$TMP_DIR" -cat > "$TMP_DIR/Sources/cmuxApp.swift" <<'SWIFT' +cat > "$TMP_DIR/Sources/CmuxAuxiliaryWindows.swift" <<'SWIFT' private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.settings", ] @@ -99,7 +99,7 @@ python3 scripts/lint_auxiliary_window_close_shortcuts.py --repo-root "$TMP_DIR" # Identifier assigned through a named constant (the MobilePairingWindowController # pattern) must be resolved and enforced, not silently skipped. -cat > "$TMP_DIR/Sources/cmuxApp.swift" <<'SWIFT' +cat > "$TMP_DIR/Sources/CmuxAuxiliaryWindows.swift" <<'SWIFT' private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.settings", ] @@ -125,7 +125,7 @@ fi grep -q "cmux.constantWindow" "$TMP_DIR/constant.out" grep -q "Sources/NewWindow.swift:8" "$TMP_DIR/constant.out" -cat > "$TMP_DIR/Sources/cmuxApp.swift" <<'SWIFT' +cat > "$TMP_DIR/Sources/CmuxAuxiliaryWindows.swift" <<'SWIFT' private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.constantWindow", "cmux.settings", From aa5b65b3fb43e70dda36719fbf05a3450e590acd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 13 Jul 2026 19:32:46 -0700 Subject: [PATCH 12/97] Make viewer retry-delay closures explicitly @Sendable An unapplied method reference is not inferred @Sendable; the app-target call site tripped the Swift warning budget. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift | 4 +++- Sources/Hive/HiveComputersService.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift index 66cf49567b89..1382a7b4bfa0 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift @@ -82,7 +82,9 @@ public struct HiveViewerRootView: View { client: client, workspaceID: selection.workspaceID, terminalID: selection.terminalID, - retryDelay: HiveReconnectBackoff().delay(attempt:) + retryDelay: { @Sendable attempt in + await HiveReconnectBackoff().delay(attempt: attempt) + } ) terminalSessions[selection] = terminal return terminal diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index c0e0b5242db4..3139cab124ab 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -70,7 +70,9 @@ final class HiveComputersService { macDeviceID: deviceID, displayName: computer.displayName, routes: best.routes, - retryDelay: HiveReconnectBackoff().delay(attempt:) + retryDelay: { @Sendable attempt in + await HiveReconnectBackoff().delay(attempt: attempt) + } ) } From 136c70d255056cbecab06eda869443ba66e8ec76 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 20:59:40 -0700 Subject: [PATCH 13/97] Computers: bordered style for the row Open button Match the section's other controls. Part of #8001 Co-Authored-By: Claude Fable 5 --- .../Sources/CmuxSettingsUI/Sections/ComputersSection.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index bdd72d0fb279..80da7779645d 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -364,7 +364,7 @@ private struct ComputersSectionRow: View { Button(String(localized: "settings.computers.row.open", defaultValue: "Open")) { open() } - .buttonStyle(.borderedProminent) + .buttonStyle(.bordered) .controlSize(.small) .accessibilityIdentifier("SettingsComputersOpenButton-\(computer.deviceID)") From 03978d7b77d9118d0fb1d6962f4bd237b1cab850 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 12:16:31 -0700 Subject: [PATCH 14/97] Move Mac-consumed mobile packages from Packages/iOS to Packages/Shared CmuxHive/CmuxHiveUI (macOS) and CmuxSyncStore (Shared) already depended on these eight packages, so per the workspace grouping rule (a package's group folder reflects its consumers) they are Shared, not iOS-only: CmuxMobilePairedMac, CmuxMobileRPC, CmuxMobileShell, CmuxMobileShellModel, CmuxMobileTerminalKit, CmuxMobileTransport, and their transitive deps CmuxMobileSupport and CmuxMobileDiagnostics. Mechanical move: git mv + .package(path:) rewrites (group-relative convention), iOS project/workspace/test-plan paths, CI workflow paths, lint baselines, docs, and a regenerated root workspace. No source changes. Co-Authored-By: Claude Fable 5 --- .github/swift-file-length-budget.tsv | 20 ++++----- .github/workflows/test-ios.yml | 4 +- .../CmuxMobileDiagnostics/Package.swift | 0 .../MobileDebugLog+Anchormux.swift | 0 .../MobileDebugLog.swift | 0 .../MobileDebugLogCrashCapture.swift | 0 .../MobileDebugLogSink.swift | 0 .../MobileDebugLogCrashCaptureTests.swift | 0 .../MobileDebugLogFileTests.swift | 0 .../MobileDebugLogSinkTests.swift | 0 .../CmuxMobilePairedMac/Package.swift | 2 +- .../CmuxMobilePairedMac/MobilePairedMac.swift | 0 .../MobilePairedMacRouteWriteCondition.swift | 0 .../MobilePairedMacStore+Records.swift | 0 .../MobilePairedMacStore+SQLite.swift | 0 .../MobilePairedMacStore.swift | 0 .../MobilePairedMacStoreError.swift | 0 .../MobilePairedMacStoring.swift | 0 .../MobilePairedMacInstanceTagTests.swift | 0 .../MobilePairedMacRouteAuthorityTests.swift | 0 .../MobilePairedMacStoreTests.swift | 0 .../CmuxMobileRPC/Package.swift | 2 +- .../CmxAttachEndpoint+LogDescription.swift | 0 .../CmuxMobileRPC/CmxAttachTicketInput.swift | 0 .../MobileCoreRPCAttachTicketCoverage.swift | 0 .../CmuxMobileRPC/MobileCoreRPCClient.swift | 0 ...reRPCSession+AbandonedConnectCleanup.swift | 0 .../CmuxMobileRPC/MobileCoreRPCSession.swift | 0 .../CmuxMobileRPC/MobileEventEnvelope.swift | 0 .../MobileEventSubscribeResponse.swift | 0 .../MobileHostStatusResponse.swift | 0 .../MobileNotificationBadgeEvent.swift | 0 .../MobileNotificationDismissedEvent.swift | 0 .../MobileNotificationReconcileResponse.swift | 0 .../MobileRPCConnectAttemptLease.swift | 0 .../MobileRPCConnectAttemptRegistry.swift | 0 .../MobileRPCConnectRouteState.swift | 0 .../MobileShellConnectionError.swift | 0 .../CmuxMobileRPC/MobileSyncRuntime.swift | 0 .../MobileSyncWorkspaceListResponse.swift | 0 .../Sources/CmuxMobileRPC/MobileSyncing.swift | 0 .../MobileTerminalBytesEvent.swift | 0 .../MobileTerminalInputResponse.swift | 0 .../MobileTerminalRenderGridEvent.swift | 0 .../MobileTerminalReplayResponse.swift | 0 .../MobileTerminalSetFontEvent.swift | 0 .../MobileTerminalViewportResponse.swift | 0 ...MobileWorkspacePreview+RemoteMapping.swift | 0 .../CmuxMobileRPC/PreviewMobileHost.swift | 0 .../CmuxMobileRPC/RPCRequestDeadline.swift | 0 .../CmuxMobileRPC/RPCStackTokenGate.swift | 0 .../CmuxMobileRPC/RPCTaskTimeout.swift | 0 .../CmuxMobileRPC/RPCTaskTimeoutRace.swift | 0 ...CancellationIgnoringConnectTransport.swift | 0 .../CancellationIgnoringTokenProvider.swift | 0 .../CmxAttachTicketInputTests.swift | 0 .../ConnectCancellationBox.swift | 0 .../FirstCallHangsTokenProvider.swift | 0 ...ectCancellationThenSucceedsTransport.swift | 0 ...rstConnectHangsThenSucceedsTransport.swift | 0 .../FixedTransportFactory.swift | 0 .../HangingCloseTransport.swift | 0 .../MobileCoreRPCAbandonedConnectTests.swift | 0 .../MobileCoreRPCClientTests.swift | 0 .../MobileCoreRPCConnectWaiterTests.swift | 0 .../MobileCoreRPCGroupActionAuthTests.swift | 0 ...leCoreRPCHostStatusTokenTimeoutTests.swift | 0 .../MobileCoreRPCRequestQueueTests.swift | 0 .../MobileCoreRPCTokenTimeoutTests.swift | 0 .../MobileTerminalDTODecodeTests.swift | 0 .../ReleasableConnectTransport.swift | 0 .../ReleasableConnectTransportFactory.swift | 0 .../ResponseTimeoutSurvivalTransport.swift | 0 ...ponseTimeoutSurvivalTransportFactory.swift | 0 .../SlowConnectTimeoutTransport.swift | 0 .../SlowConnectTimeoutTransportFactory.swift | 0 .../TransportTestDoubles.swift | 0 .../CmuxMobileShell/Package.swift | 4 +- ...ingUpPairedMacStore+AuthorizedRoutes.swift | 0 ...gUpPairedMacStore+ConditionalRestore.swift | 0 .../BackingUpPairedMacStore+ScopeKey.swift | 0 .../BackingUpPairedMacStore.swift | 0 .../CmxAttachTicket+ConstrainingRoutes.swift | 0 ...eteComputersVerifierIdentityProvider.swift | 0 ...eleteComputersVerifierPairedMacStore.swift | 0 .../DeliveredNotificationClearing.swift | 0 .../DeviceRegistryRefreshing.swift | 0 .../DeviceRegistryService.swift | 0 .../IOSBuildScopedPairedMacStore.swift | 0 .../InMemoryPairedMacForgottenStore.swift | 0 .../InMemoryPairedMacPendingDeleteStore.swift | 0 .../InMemoryTerminalDraftStore.swift | 0 .../CmuxMobileShell/MacBuildChannel.swift | 0 .../CmuxMobileShell/MacConnection.swift | 0 .../MobileChatEventSource.swift | 0 .../MobileChatSessionResponse.swift | 0 .../MobileChatSessionsResponse.swift | 0 ...eleteComputersVerificationCheckpoint.swift | 0 ...ileDeleteComputersVerificationResult.swift | 0 ...DeleteComputersVerificationWorkspace.swift | 0 .../MobileDeleteComputersVerifier.swift | 0 .../MobileFeedbackEmailClient.swift | 0 .../MobileFeedbackEmailError.swift | 0 .../MobileFeedbackEmailSubmitting.swift | 0 .../CmuxMobileShell/MobileMacAppVersion.swift | 0 .../MobileMacInstanceTagAuthority.swift | 0 ...MobileMacUpdateCapabilityRequirement.swift | 0 .../MobileMacUpdateFeature.swift | 0 .../CmuxMobileShell/MobileMacUpdateHint.swift | 0 .../MobileMacUpdateHintDismissalStore.swift | 0 ...bileManualAttachTicketCreateResponse.swift | 0 .../MobilePairedMacBackup.swift | 0 .../MobilePairingAccountPreflight.swift | 0 .../MobilePairingFailure.swift | 0 .../MobileShellComposite+AgentChat.swift | 0 ...MobileShellComposite+AltScreenNotice.swift | 0 .../MobileShellComposite+Capabilities.swift | 0 ...ileShellComposite+DeeplinkNavigation.swift | 0 ...bileShellComposite+DisplayFormatting.swift | 0 ...MobileShellComposite+DogfoodFeedback.swift | 0 .../MobileShellComposite+ForgottenMacs.swift | 0 .../MobileShellComposite+Helpers.swift | 0 ...bileShellComposite+InstanceAuthority.swift | 0 .../MobileShellComposite+MacSwitchState.swift | 0 .../MobileShellComposite+MacUpdateHint.swift | 0 ...ileShellComposite+ManualAttachTicket.swift | 0 ...ellComposite+NotificationDismissSync.swift | 0 ...obileShellComposite+PairedMacAliases.swift | 0 ...leShellComposite+PairedMacCoalescing.swift | 0 ...eShellComposite+PairedMacPersistence.swift | 0 ...bileShellComposite+PresenceRouteSync.swift | 0 ...MobileShellComposite+ReconnectRoutes.swift | 0 .../MobileShellComposite+Scope.swift | 0 ...ileShellComposite+SecondaryPromotion.swift | 0 ...hellComposite+TerminalOutputDelivery.swift | 0 ...ellComposite+TerminalReplayLifecycle.swift | 0 ...leShellComposite+TerminalReplayRetry.swift | 0 ...hellComposite+TerminalScrollDelivery.swift | 0 ...obileShellComposite+TerminalViewport.swift | 0 ...ellComposite+UITestWorkspaceCreation.swift | 0 ...obileShellComposite+WorkspaceActions.swift | 0 ...hellComposite+WorkspaceCreateRequest.swift | 0 ...ShellComposite+WorkspaceListRecovery.swift | 0 ...posite+WorkspaceMutationCapabilities.swift | 0 .../MobileShellComposite.swift | 0 ...hellMacAvailabilityFailureClassifier.swift | 0 .../MobileShellScopeSnapshot.swift | 0 ...leShellWorkspaceMutationTicketPolicy.swift | 0 ...nderGridFrame+TerminalOutputDelivery.swift | 0 .../MobileTerminalViewportKey.swift | 0 .../MobileWorkspaceMutationFailure.swift | 0 .../MultiMacAggregationFlag.swift | 0 .../NoopDeliveredNotificationClearer.swift | 0 .../CmuxMobileShell/PairedMacBackingUp.swift | 0 .../PairedMacBackupClient.swift | 0 .../PairedMacBackupFailableRecord.swift | 0 .../PairedMacBackupFailableRoute.swift | 0 .../PairedMacBackupListResponse.swift | 0 .../CmuxMobileShell/PairedMacBackupOp.swift | 0 .../PairedMacBackupOpWire.swift | 0 .../PairedMacBackupRecord.swift | 0 .../PairedMacBackupRecordWire.swift | 0 .../PairedMacBackupRefreshing.swift | 0 .../PairedMacBackupRequestBody.swift | 0 .../PairedMacBackupSnapshot.swift | 0 .../PairedMacForgottenStoring.swift | 0 .../PairedMacMutationGate.swift | 0 .../PairedMacPendingDeleteStoring.swift | 0 .../CmuxMobileShell/PairedMacRestore.swift | 0 .../PairedMacRestoreBoundary.swift | 0 .../PendingNotificationDismissQueue.swift | 0 .../CmuxMobileShell/PresenceClient.swift | 0 .../CmuxMobileShell/PresenceClientError.swift | 0 .../CmuxMobileShell/PresenceDevice.swift | 0 .../CmuxMobileShell/PresenceInstance.swift | 0 .../Sources/CmuxMobileShell/PresenceMap.swift | 0 .../PresenceOfflineReason.swift | 0 .../PresenceServiceConfiguration.swift | 0 .../CmuxMobileShell/PresenceSnapshot.swift | 0 .../CmuxMobileShell/PresenceSubscribing.swift | 0 .../CmuxMobileShell/PresenceTokenSource.swift | 0 .../CmuxMobileShell/PresenceUpdate.swift | 0 .../ResolveForegroundMacID.swift | 0 .../CmuxMobileShell/RestoreOutcome.swift | 0 .../SecondaryClientHandle.swift | 0 .../SecondaryMacSubscription.swift | 0 .../SystemDeliveredNotificationClearer.swift | 0 .../TeamScopedPairedMacStore.swift | 0 .../TerminalDraftStoring.swift | 0 .../TerminalOutputDelivery.swift | 0 .../TerminalScrollDelivery.swift | 0 .../TerminalTextSnapshot.swift | 0 .../UserDefaultsPairedMacForgottenStore.swift | 0 ...rDefaultsPairedMacPendingDeleteStore.swift | 0 .../WorkspaceMutationTarget.swift | 0 .../AlwaysOnlineReachability.swift | 0 .../ComposerDefaultOpenTests.swift | 0 .../ComposerPendingAttachmentTests.swift | 0 .../ComposerSubmitDraftReconcileTests.swift | 0 .../ComposerSubmitRoutingTestSupport.swift | 0 .../ComposerSubmitRoutingTests.swift | 0 .../CountingDraftStore.swift | 0 ...ingSlowIgnoringCancellationTransport.swift | 0 ...IgnoringCancellationTransportFactory.swift | 0 .../DelayedTeamDeviceRegistry.swift | 0 .../DelayedTeamPairedMacStore.swift | 0 .../DelayingDraftStore.swift | 0 .../DeviceRegistryListParsingTests.swift | 0 .../DeviceRegistryRouteSelectionTests.swift | 0 .../DraftPipelineOrderingTests.swift | 0 .../DraftSwitchOwnershipTests.swift | 0 .../CmuxMobileShellTests/FakeBackup.swift | 0 .../GatedUpsertStore.swift | 0 .../IOSBuildScopedPairedMacStoreTests.swift | 0 .../InMemoryTerminalDraftStoreTests.swift | 0 ...venessHostRouterAttachTicketResponse.swift | 0 .../LivenessViewportReport.swift | 0 .../MacBuildChannelTests.swift | 0 .../MobileFeedbackEmailClientTests.swift | 0 .../MobileMacAppVersionTests.swift | 0 ...eMacInstanceConnectionAuthorityTests.swift | 0 .../MobileMacInstanceRouteRaceTests.swift | 0 .../MobileMacInstanceTagAuthorityTests.swift | 0 ...bileMacUpdateHintDismissalStoreTests.swift | 0 .../MobileMacUpdateHintTests.swift | 0 .../MobilePairingAttemptDeadlineTests.swift | 0 .../MobilePairingFailureTests.swift | 0 ...obileSecondaryInstanceAuthorityTests.swift | 0 .../MobileShellAltScreenNoticeTests.swift | 0 ...ileShellColdAttachReplayBarrierTests.swift | 0 ...ileShellCompositeAgentChatCacheTests.swift | 0 ...ileShellCompositeColorSlotResetTests.swift | 0 .../MobileShellCompositeForgetMacTests.swift | 0 ...ellCompositeForgottenMacRefreshTests.swift | 0 ...ellCompositePairedMacCoalescingTests.swift | 0 .../MobileShellCompositePreviewTests.swift | 0 .../MobileShellDismissSyncTests.swift | 0 .../MobileShellForegroundResumeTests.swift | 0 ...ileShellRenderGridEventFrameFixtures.swift | 0 ...obileShellRenderGridFrameTestSupport.swift | 0 ...bileShellRenderGridInputCatchUpTests.swift | 0 ...hellRenderGridLivenessRuntimeSupport.swift | 0 ...leShellRenderGridLivenessTestSupport.swift | 0 .../MobileShellRenderGridLivenessTests.swift | 0 ...RenderGridReplayRetryExhaustionTests.swift | 0 ...eShellRenderGridReplayStalenessTests.swift | 0 .../MobileShellReplayDropRecoveryTests.swift | 0 ...MobileShellReplayFallbackScreenTests.swift | 0 .../MobileShellWorkspaceCapabilityTests.swift | 0 .../MobileShellWorkspaceCreateTests.swift | 0 .../MultiMacAggregationFlagTests.swift | 0 .../CmuxMobileShellTests/MutableBackup.swift | 0 .../CmuxMobileShellTests/MutableTeam.swift | 0 .../CmuxMobileShellTests/MutableTeamID.swift | 0 .../PairedMacBackupTests.swift | 0 .../PairedMacInstanceTagBackupTests.swift | 0 .../PairingDeadlineRuntime.swift | 0 ...PendingNotificationDismissQueueTests.swift | 0 .../PresenceMapTests.swift | 0 .../PresenceServiceURLResolutionTests.swift | 0 .../PresenceWireParsingTests.swift | 0 .../ReconnectRouteSelectionTests.swift | 0 ...ecordingDeliveredNotificationClearer.swift | 0 .../ResolveForegroundMacIDTests.swift | 0 .../SlowIgnoringCancellationTransport.swift | 0 ...IgnoringCancellationTransportFactory.swift | 0 .../StaticIdentityProvider.swift | 0 ...ggedBuildPresenceRouteIsolationTests.swift | 0 ...ggedBuildRegistryRouteIsolationTests.swift | 0 .../TeamScopedPairedMacStoreTests.swift | 0 .../TerminalByteGapRebaseTests.swift | 0 ...TerminalColdAttachReplayBarrierTests.swift | 0 ...inalMissingBaselineReplayBudgetTests.swift | 0 .../TerminalOutputDeliveryQueueTests.swift | 0 .../TerminalReplayBarrierAckResetTests.swift | 0 .../TerminalReplayBarrierFollowUpTests.swift | 0 .../TerminalReplayOutputQueueTests.swift | 0 .../TerminalScrollDeliveryQueueTests.swift | 0 .../TerminalStaleFloorByteChannelTests.swift | 0 ...rminalStaleFrameRecoveryBarrierTests.swift | 0 .../TerminalTextSnapshotTests.swift | 0 .../TerminalThemeApplyTests.swift | 0 .../TerminalViewportResyncTests.swift | 0 .../CmuxMobileShellTests/TokenProbe.swift | 0 .../CmuxMobileShellModel/Package.swift | 2 +- .../MacWorkspaceState.swift | 0 .../MachineAvatarPalette.swift | 0 .../MobileBuildType.swift | 0 .../MobileClientIDRepository.swift | 0 .../MobileConnectionState.swift | 0 .../MobileFeedbackRoute.swift | 0 .../MobileFeedbackStamp.swift | 0 .../MobileIdentityProviding.swift | 0 .../MobileMacConnectionStatus.swift | 0 .../MobileOnboardingStore.swift | 0 .../MobilePairingURLConnectionResult.swift | 0 .../MobilePendingAttachment.swift | 0 .../MobileRelativeActivity.swift | 0 .../MobileShellPhase.swift | 0 .../MobileShellRouteAuthPolicy.swift | 0 .../MobileTerminalInputEnqueueResult.swift | 0 .../MobileTerminalInputSendBuffer.swift | 0 .../MobileTerminalOutputSinking.swift | 0 .../MobileTerminalPreview.swift | 0 .../MobileTerminalViewportFit.swift | 0 .../MobileTerminalViewportSize.swift | 0 .../MobileWorkspaceActionCapabilities.swift | 0 .../MobileWorkspaceAggregation.swift | 0 .../MobileWorkspaceGroupCollapseStore.swift | 0 .../MobileWorkspaceGroupPreview.swift | 0 .../MobileWorkspaceListFilter.swift | 0 .../MobileWorkspaceListItem+MoveIntent.swift | 0 .../MobileWorkspaceListItem.swift | 0 ...obileWorkspaceListMoveIntentResolver.swift | 0 .../MobileWorkspaceMoveIntent.swift | 0 .../MobileWorkspaceMovePolicy.swift | 0 .../MobileWorkspaceOptimisticOrder.swift | 0 .../MobileWorkspaceOptimisticOrderEntry.swift | 0 ...leWorkspaceOptimisticOrderReconciler.swift | 0 .../MobileWorkspaceOrderMoveApplier.swift | 0 .../MobileWorkspaceOrderSignature.swift | 0 .../MobileWorkspacePreview+WindowScope.swift | 0 .../MobileWorkspacePreview.swift | 0 .../MobileWorkspaceReadStateFilter.swift | 0 .../CmuxMobileShellModel/RegistryDevice.swift | 0 .../MachineAvatarPaletteTests.swift | 0 .../MobileClientIDRepositoryTests.swift | 0 .../MobileFeedbackRouteTests.swift | 0 .../MobileOnboardingStoreTests.swift | 0 .../MobileRelativeActivityTests.swift | 0 .../MobileShellRouteAuthPolicyTests.swift | 0 .../MobileTerminalInputSendBufferTests.swift | 0 .../MobileTerminalViewportFitTests.swift | 0 .../MobileWorkspaceAggregationTests.swift | 0 ...bileWorkspaceGroupCollapseStoreTests.swift | 0 .../MobileWorkspaceHostOrderSimulator.swift | 0 .../MobileWorkspaceListFilterTests.swift | 0 ...MobileWorkspaceListItemMoveEdgeTests.swift | 0 .../MobileWorkspaceListItemTests.swift | 0 ...MobileWorkspaceMovePolicyParityTests.swift | 0 .../MobileWorkspaceMovePolicyTests.swift | 0 .../MobileWorkspaceOptimisticOrderTests.swift | 0 .../MobileWorkspaceWindowScopeTests.swift | 0 .../CmuxMobileSupport/Package.swift | 0 .../CmuxMobileSupport/AppVersionInfo.swift | 0 .../ComposerDictationAudioEngine.swift | 0 .../ComposerDictationController.swift | 0 .../ComposerDictationStartDisposition.swift | 0 .../ComposerDictationTextMerger.swift | 0 ...Config+RefreshingTerminalMenuPreview.swift | 0 .../Sources/CmuxMobileSupport/L10n.swift | 0 .../MobileCompactToolbarTitleStack.swift | 0 .../MobileComposerFieldContainer.swift | 0 .../MobileComposerIconButton.swift | 0 .../MobileComposerIconLabel.swift | 0 .../MobileKeyboardReservation.swift | 0 .../MobileKeyboardTransition.swift | 0 .../MobileKeyboardVisibility.swift | 0 .../MobileScrollViewportSnapshot.swift | 0 .../CmuxMobileSupport/UITestConfig.swift | 0 .../UITestEnvironmentConfig.swift | 0 .../CmuxMobileSupport/View+MobileGlass.swift | 0 .../AppVersionInfoTests.swift | 0 .../ComposerDictationTests.swift | 0 .../CmuxMobileSupportTests/L10nTests.swift | 0 .../MobileKeyboardTrackingTests.swift | 0 .../UITestConfigTests.swift | 0 .../CmuxMobileTerminalKit/Package.swift | 2 +- .../CustomToolbarAction.swift | 0 .../TerminalAccessoryLayoutReducer.swift | 0 .../TerminalArrowRepeatService.swift | 0 .../TerminalColumnReportWidthSelection.swift | 0 .../TerminalCursorBlinkState.swift | 0 .../TerminalDECTCEMCursorScanner.swift | 0 .../TerminalFontZoomDirection.swift | 0 .../TerminalInputModifier.swift | 0 .../TerminalInputModifierState.swift | 0 .../TerminalKeyEncoder.swift | 0 .../TerminalKeyModifier+Codable.swift | 0 .../TerminalKeyModifier.swift | 0 .../TerminalLetterboxGeometry.swift | 0 .../TerminalRowCapacityFit.swift | 0 .../TerminalSpecialKey.swift | 0 .../TerminalTextInputPipeline.swift | 0 .../ToolbarActionPayload.swift | 0 .../CmuxMobileTerminalKit/ToolbarItemID.swift | 0 .../ToolbarLayoutMigration.swift | 0 .../TerminalAccessoryLayoutReducerTests.swift | 0 .../TerminalArrowRepeatServiceTests.swift | 0 .../TerminalInputModifierStateTests.swift | 0 .../TerminalKeyEncoderTests.swift | 0 .../TerminalLetterboxGeometryTests.swift | 0 .../TerminalRowCapacityFitTests.swift | 0 .../TerminalValueTests.swift | 0 .../ToolbarCustomizationTests.swift | 0 .../ToolbarLayoutMigrationFoldTests.swift | 0 .../CmuxMobileTransport/Package.swift | 2 +- .../CmxNetworkByteTransport.swift | 0 .../CmxNetworkRoutePinger.swift | 0 .../NetworkInterfaceAddress.swift | 0 .../NetworkInterfaceAddressProviding.swift | 0 .../NetworkReachability.swift | 0 .../ReachabilityProviding.swift | 0 .../ReachabilityService.swift | 0 ...ystemNetworkInterfaceAddressProvider.swift | 0 .../CmuxMobileTransport/TailscaleStatus.swift | 0 .../TailscaleStatusMonitor.swift | 0 .../CmxNetworkByteTransportTests.swift | 0 .../CmxRoutePingTests.swift | 0 .../PingTestListener.swift | 0 .../ReachabilityServiceTests.swift | 0 .../TailscaleStatusTests.swift | 0 Packages/Shared/CmuxSyncStore/Package.swift | 4 +- Packages/iOS/CmuxAgentChatUI/Package.swift | 2 +- Packages/iOS/CmuxMobileBrowser/Package.swift | 2 +- Packages/iOS/CmuxMobileShellUI/Package.swift | 12 +++--- Packages/iOS/CmuxMobileTerminal/Package.swift | 6 +-- .../iOS/CmuxMobileWorkspace/Package.swift | 2 +- Packages/macOS/CmuxHive/Package.swift | 12 +++--- Packages/macOS/CmuxHiveUI/Package.swift | 2 +- cmux.xcworkspace/contents.xcworkspacedata | 42 +++++++++---------- docs/presence-service.md | 2 +- ios/cmuxPackage/Package.swift | 16 +++---- plans/feat-ios-iroh/DESIGN.md | 2 +- scripts/lint-namespace-types-baseline.txt | 18 ++++---- 425 files changed, 80 insertions(+), 80 deletions(-) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Package.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog+Anchormux.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogCrashCapture.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogSink.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogCrashCaptureTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogFileTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogSinkTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Package.swift (95%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMac.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacRouteWriteCondition.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Records.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+SQLite.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoreError.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoring.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacInstanceTagTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacRouteAuthorityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Package.swift (95%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachEndpoint+LogDescription.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCAttachTicketCoverage.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+AbandonedConnectCleanup.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventEnvelope.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptLease.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptRegistry.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectRouteState.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileShellConnectionError.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncing.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalBytesEvent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalInputResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalRenderGridEvent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalReplayResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalSetFontEvent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalViewportResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCRequestDeadline.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeout.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutRace.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringConnectTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectCancellationBox.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstCallHangsTokenProvider.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectCancellationThenSucceedsTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectHangsThenSucceedsTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FixedTransportFactory.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/HangingCloseTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCAbandonedConnectTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCConnectWaiterTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTerminalDTODecodeTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransportFactory.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransportFactory.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransportFactory.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Package.swift (94%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+AuthorizedRoutes.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ConditionalRestore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ScopeKey.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierIdentityProvider.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierPairedMacStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacForgottenStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacPendingDeleteStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryTerminalDraftStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildChannel.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MacConnection.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionsResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationCheckpoint.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationResult.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationWorkspace.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerifier.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailClient.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailError.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailSubmitting.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagAuthority.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairedMacBackup.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingAccountPreflight.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DisplayFormatting.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DogfoodFeedback.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ForgottenMacs.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+InstanceAuthority.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacAliases.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Scope.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayRetry.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalViewport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+UITestWorkspaceCreation.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceListRecovery.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceMutationCapabilities.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellMacAvailabilityFailureClassifier.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellScopeSnapshot.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellWorkspaceMutationTicketPolicy.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportKey.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/MultiMacAggregationFlag.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackingUp.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRecord.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRoute.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupListResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOp.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOpWire.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecord.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecordWire.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRefreshing.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRequestBody.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupSnapshot.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacForgottenStoring.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacMutationGate.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacPendingDeleteStoring.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestoreBoundary.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClientError.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceDevice.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceInstance.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceOfflineReason.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSnapshot.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSubscribing.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceTokenSource.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/PresenceUpdate.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/ResolveForegroundMacID.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/RestoreOutcome.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/TerminalDraftStoring.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/TerminalTextSnapshot.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacForgottenStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacPendingDeleteStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceMutationTarget.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/AlwaysOnlineReachability.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerDefaultOpenTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitDraftReconcileTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingDraftStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransportFactory.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamDeviceRegistry.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayingDraftStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftPipelineOrderingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftSwitchOwnershipTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/FakeBackup.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/GatedUpsertStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryTerminalDraftStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessHostRouterAttachTicketResponse.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessViewportReport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MacBuildChannelTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileFeedbackEmailClientTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceRouteRaceTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceTagAuthorityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingAttemptDeadlineTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSecondaryInstanceAuthorityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellColdAttachReplayBarrierTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeAgentChatCacheTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeColorSlotResetTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetMacTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgottenMacRefreshTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairedMacCoalescingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundResumeTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridEventFrameFixtures.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridFrameTestSupport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessRuntimeSupport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayRetryExhaustionTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayStalenessTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayDropRecoveryTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayFallbackScreenTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MultiMacAggregationFlagTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableBackup.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeam.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeamID.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacInstanceTagBackupTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PairingDeadlineRuntime.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceServiceURLResolutionTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceWireParsingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/ResolveForegroundMacIDTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransportFactory.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/StaticIdentityProvider.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TeamScopedPairedMacStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalByteGapRebaseTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalColdAttachReplayBarrierTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalMissingBaselineReplayBudgetTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierAckResetTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierFollowUpTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayOutputQueueTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFloorByteChannelTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFrameRecoveryBarrierTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalTextSnapshotTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeApplyTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShell/Tests/CmuxMobileShellTests/TokenProbe.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Package.swift (95%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacWorkspaceState.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MachineAvatarPalette.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileClientIDRepository.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionState.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackRoute.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackStamp.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileIdentityProviding.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacConnectionStatus.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileRelativeActivity.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellPhase.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputEnqueueResult.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputSendBuffer.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportFit.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportSize.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceAggregation.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupCollapseStore.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListFilter.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMoveIntent.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview+WindowScope.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceReadStateFilter.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MachineAvatarPaletteTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileClientIDRepositoryTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileRelativeActivityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalInputSendBufferTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalViewportFitTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceAggregationTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupCollapseStoreTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListFilterTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceWindowScopeTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Package.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/AppVersionInfo.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationAudioEngine.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationStartDisposition.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+RefreshingTerminalMenuPreview.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconButton.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardReservation.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibility.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Tests/CmuxMobileSupportTests/AppVersionInfoTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Tests/CmuxMobileSupportTests/ComposerDictationTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Tests/CmuxMobileSupportTests/L10nTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Package.swift (95%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalArrowRepeatService.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalColumnReportWidthSelection.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorBlinkState.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalFontZoomDirection.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifier.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifierState.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier+Codable.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalRowCapacityFit.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSpecialKey.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalArrowRepeatServiceTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputModifierStateTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyEncoderTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalLetterboxGeometryTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalRowCapacityFitTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalValueTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarLayoutMigrationFoldTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Package.swift (95%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkRoutePinger.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddress.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddressProviding.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkReachability.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityProviding.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityService.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/SystemNetworkInterfaceAddressProvider.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatus.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatusMonitor.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxRoutePingTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Tests/CmuxMobileTransportTests/PingTestListener.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ReachabilityServiceTests.swift (100%) rename Packages/{iOS => Shared}/CmuxMobileTransport/Tests/CmuxMobileTransportTests/TailscaleStatusTests.swift (100%) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 1b3e7ff303d8..86506534dcef 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -12,7 +12,7 @@ 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7959 Sources/Panels/BrowserPanelView.swift -7736 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +7736 Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 7489 cmuxTests/WorkspaceUnitTests.swift 7310 cmuxTests/WorkspaceRemoteConnectionTests.swift 6359 cmuxTests/SessionPersistenceTests.swift @@ -89,11 +89,11 @@ 1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift 1049 cmuxTests/WorkspaceGroupTests.swift 1038 Sources/AppDelegate+CmuxSSHURL.swift -1033 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift +1033 Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1009 cmuxTests/CmuxTopSnapshotScopeTests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift -1002 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift +1002 Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift 999 cmuxTests/DockSocketLifecycleTests.swift 982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift 974 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -118,19 +118,19 @@ 856 Sources/TextBoxMentionIndexStore.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift -841 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift +841 Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift 834 Sources/MainWindowFocusController.swift 825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 822 Sources/WorkspaceContentView.swift 812 Sources/TerminalController+ControlPaneContext.swift 810 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift -803 Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift +803 Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift 801 Sources/ClosedItemHistory.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 773 Sources/App/MenuBarExtraController.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift -762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift +762 Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift 754 cmuxTests/GhosttyTerminalStartupEnvironmentTests.swift 753 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -163,7 +163,7 @@ 664 Sources/CmuxTopSnapshot.swift 663 Sources/PortScanner.swift 661 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift -660 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift +660 Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift 655 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 654 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift 650 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Import/Detection/BrowserInstalledBrowserDetector.swift @@ -173,7 +173,7 @@ 644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 642 cmuxTests/RemoteTmuxControlParserTests.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift -638 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift +638 Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift 635 Sources/Panels/BrowserNavigationDelegate.swift 635 cmuxUITests/RightSidebarChromeHeightUITests.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift @@ -197,7 +197,7 @@ 586 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift 586 Sources/JSONCParser.swift 585 cmuxTests/SettingsWindowPresenterTests.swift -583 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +583 Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift 580 Packages/macOS/CmuxExtensionKit/Tests/CmuxExtensionKitTests/CmuxExtensionKitTests.swift 580 cmuxTests/CLIHookNoResponseTests.swift 579 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift @@ -208,7 +208,7 @@ 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 567 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift -562 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift +562 Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift 560 cmuxTests/CLISSHPTYResizeInputTests.swift diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index d2ff3a30a274..64d7af82fbb6 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -173,14 +173,14 @@ jobs: - name: Run CmuxMobilePairedMac package tests run: | - swift test --package-path Packages/iOS/CmuxMobilePairedMac + swift test --package-path Packages/Shared/CmuxMobilePairedMac - name: Run CmuxMobileShell package tests run: | # iOS shell replay/liveness regressions live in this package target. # Keep them under the iOS package gate so PR CI enforces the mobile # terminal mirror behavior without relying on local-only SwiftPM runs. - swift test --package-path Packages/iOS/CmuxMobileShell + swift test --package-path Packages/Shared/CmuxMobileShell ios-simulator: needs: detect-ios-changes diff --git a/Packages/iOS/CmuxMobileDiagnostics/Package.swift b/Packages/Shared/CmuxMobileDiagnostics/Package.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Package.swift rename to Packages/Shared/CmuxMobileDiagnostics/Package.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog+Anchormux.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog+Anchormux.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog+Anchormux.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog+Anchormux.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLog.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogCrashCapture.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogCrashCapture.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogCrashCapture.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogCrashCapture.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogSink.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogSink.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogSink.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogSink.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogCrashCaptureTests.swift b/Packages/Shared/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogCrashCaptureTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogCrashCaptureTests.swift rename to Packages/Shared/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogCrashCaptureTests.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogFileTests.swift b/Packages/Shared/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogFileTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogFileTests.swift rename to Packages/Shared/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogFileTests.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogSinkTests.swift b/Packages/Shared/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogSinkTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogSinkTests.swift rename to Packages/Shared/CmuxMobileDiagnostics/Tests/CmuxMobileDiagnosticsTests/MobileDebugLogSinkTests.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Package.swift b/Packages/Shared/CmuxMobilePairedMac/Package.swift similarity index 95% rename from Packages/iOS/CmuxMobilePairedMac/Package.swift rename to Packages/Shared/CmuxMobilePairedMac/Package.swift index 514c5200fda1..f61c6fe576fe 100644 --- a/Packages/iOS/CmuxMobilePairedMac/Package.swift +++ b/Packages/Shared/CmuxMobilePairedMac/Package.swift @@ -15,7 +15,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CMUXMobileCore"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMac.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMac.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMac.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMac.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacRouteWriteCondition.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacRouteWriteCondition.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacRouteWriteCondition.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacRouteWriteCondition.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Records.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Records.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Records.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Records.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+SQLite.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+SQLite.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+SQLite.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+SQLite.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoreError.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoreError.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoreError.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoreError.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoring.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoring.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStoring.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacInstanceTagTests.swift b/Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacInstanceTagTests.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacInstanceTagTests.swift rename to Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacInstanceTagTests.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacRouteAuthorityTests.swift b/Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacRouteAuthorityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacRouteAuthorityTests.swift rename to Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacRouteAuthorityTests.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift b/Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift rename to Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacStoreTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Package.swift b/Packages/Shared/CmuxMobileRPC/Package.swift similarity index 95% rename from Packages/iOS/CmuxMobileRPC/Package.swift rename to Packages/Shared/CmuxMobileRPC/Package.swift index 4df92733cd08..45d0f47564c7 100644 --- a/Packages/iOS/CmuxMobileRPC/Package.swift +++ b/Packages/Shared/CmuxMobileRPC/Package.swift @@ -15,7 +15,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CMUXMobileCore"), .package(path: "../CmuxMobileShellModel"), .package(path: "../CmuxMobileSupport"), ], diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachEndpoint+LogDescription.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachEndpoint+LogDescription.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachEndpoint+LogDescription.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachEndpoint+LogDescription.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCAttachTicketCoverage.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCAttachTicketCoverage.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCAttachTicketCoverage.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCAttachTicketCoverage.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+AbandonedConnectCleanup.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+AbandonedConnectCleanup.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+AbandonedConnectCleanup.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+AbandonedConnectCleanup.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventEnvelope.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventEnvelope.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventEnvelope.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventEnvelope.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileEventSubscribeResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostStatusResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationBadgeEvent.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationDismissedEvent.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationReconcileResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptLease.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptLease.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptLease.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptLease.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptRegistry.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptRegistry.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptRegistry.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptRegistry.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectRouteState.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectRouteState.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectRouteState.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectRouteState.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileShellConnectionError.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileShellConnectionError.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileShellConnectionError.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileShellConnectionError.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncRuntime.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncing.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncing.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncing.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncing.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalBytesEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalBytesEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalBytesEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalBytesEvent.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalInputResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalInputResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalInputResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalInputResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalRenderGridEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalRenderGridEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalRenderGridEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalRenderGridEvent.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalReplayResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalReplayResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalReplayResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalReplayResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalSetFontEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalSetFontEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalSetFontEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalSetFontEvent.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalViewportResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalViewportResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalViewportResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalViewportResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspacePreview+RemoteMapping.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCRequestDeadline.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCRequestDeadline.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCRequestDeadline.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCRequestDeadline.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCStackTokenGate.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeout.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeout.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeout.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeout.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutRace.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutRace.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutRace.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutRace.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringConnectTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringConnectTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringConnectTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringConnectTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachTicketInputTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectCancellationBox.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectCancellationBox.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectCancellationBox.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ConnectCancellationBox.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstCallHangsTokenProvider.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstCallHangsTokenProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstCallHangsTokenProvider.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstCallHangsTokenProvider.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectCancellationThenSucceedsTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectCancellationThenSucceedsTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectCancellationThenSucceedsTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectCancellationThenSucceedsTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectHangsThenSucceedsTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectHangsThenSucceedsTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectHangsThenSucceedsTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectHangsThenSucceedsTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FixedTransportFactory.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FixedTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FixedTransportFactory.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FixedTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/HangingCloseTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/HangingCloseTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/HangingCloseTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/HangingCloseTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCAbandonedConnectTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCAbandonedConnectTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCAbandonedConnectTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCAbandonedConnectTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCClientTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCConnectWaiterTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCConnectWaiterTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCConnectWaiterTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCConnectWaiterTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCGroupActionAuthTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCHostStatusTokenTimeoutTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCRequestQueueTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTerminalDTODecodeTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTerminalDTODecodeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTerminalDTODecodeTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTerminalDTODecodeTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransportFactory.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransportFactory.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ReleasableConnectTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransportFactory.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransportFactory.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ResponseTimeoutSurvivalTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransportFactory.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransportFactory.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SlowConnectTimeoutTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportTestDoubles.swift diff --git a/Packages/iOS/CmuxMobileShell/Package.swift b/Packages/Shared/CmuxMobileShell/Package.swift similarity index 94% rename from Packages/iOS/CmuxMobileShell/Package.swift rename to Packages/Shared/CmuxMobileShell/Package.swift index 6dc606bee56c..74a6774f01e7 100644 --- a/Packages/iOS/CmuxMobileShell/Package.swift +++ b/Packages/Shared/CmuxMobileShell/Package.swift @@ -15,8 +15,8 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../../Shared/CmuxAgentChat"), + .package(path: "../CMUXMobileCore"), + .package(path: "../CmuxAgentChat"), .package(path: "../CmuxMobileDiagnostics"), .package(path: "../CmuxMobilePairedMac"), .package(path: "../CmuxMobileRPC"), diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+AuthorizedRoutes.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+AuthorizedRoutes.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+AuthorizedRoutes.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+AuthorizedRoutes.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ConditionalRestore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ConditionalRestore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ConditionalRestore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ConditionalRestore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ScopeKey.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ScopeKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ScopeKey.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore+ScopeKey.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/CmxAttachTicket+ConstrainingRoutes.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierIdentityProvider.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierIdentityProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierIdentityProvider.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierIdentityProvider.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeleteComputersVerifierPairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeliveredNotificationClearing.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryRefreshing.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/IOSBuildScopedPairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacForgottenStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacForgottenStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacForgottenStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacForgottenStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacPendingDeleteStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacPendingDeleteStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacPendingDeleteStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacPendingDeleteStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryTerminalDraftStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryTerminalDraftStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryTerminalDraftStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryTerminalDraftStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildChannel.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildChannel.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildChannel.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildChannel.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MacConnection.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacConnection.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MacConnection.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacConnection.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionResponse.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionsResponse.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionsResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionsResponse.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatSessionsResponse.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationCheckpoint.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationCheckpoint.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationCheckpoint.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationCheckpoint.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationResult.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationResult.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationResult.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationResult.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationWorkspace.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationWorkspace.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationWorkspace.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerificationWorkspace.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerifier.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerifier.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerifier.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDeleteComputersVerifier.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailClient.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailClient.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailClient.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailClient.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailError.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailError.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailError.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailError.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailSubmitting.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailSubmitting.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailSubmitting.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileFeedbackEmailSubmitting.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagAuthority.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagAuthority.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagAuthority.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagAuthority.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileManualAttachTicketCreateResponse.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairedMacBackup.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairedMacBackup.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairedMacBackup.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairedMacBackup.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingAccountPreflight.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingAccountPreflight.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingAccountPreflight.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingAccountPreflight.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DeeplinkNavigation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DisplayFormatting.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DisplayFormatting.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DisplayFormatting.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DisplayFormatting.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DogfoodFeedback.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DogfoodFeedback.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DogfoodFeedback.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DogfoodFeedback.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ForgottenMacs.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ForgottenMacs.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ForgottenMacs.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ForgottenMacs.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Helpers.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+InstanceAuthority.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+InstanceAuthority.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+InstanceAuthority.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+InstanceAuthority.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacSwitchState.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationDismissSync.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacAliases.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacAliases.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacAliases.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacAliases.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacPersistence.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ReconnectRoutes.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Scope.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Scope.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Scope.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Scope.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SecondaryPromotion.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayLifecycle.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayRetry.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayRetry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayRetry.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalReplayRetry.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalViewport.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalViewport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalViewport.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalViewport.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+UITestWorkspaceCreation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+UITestWorkspaceCreation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+UITestWorkspaceCreation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+UITestWorkspaceCreation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceActions.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceCreateRequest.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceListRecovery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceListRecovery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceListRecovery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceListRecovery.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceMutationCapabilities.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceMutationCapabilities.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceMutationCapabilities.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceMutationCapabilities.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellMacAvailabilityFailureClassifier.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellMacAvailabilityFailureClassifier.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellMacAvailabilityFailureClassifier.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellMacAvailabilityFailureClassifier.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellScopeSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellScopeSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellScopeSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellScopeSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellWorkspaceMutationTicketPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellWorkspaceMutationTicketPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellWorkspaceMutationTicketPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellWorkspaceMutationTicketPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalRenderGridFrame+TerminalOutputDelivery.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportKey.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportKey.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportKey.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceMutationFailure.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MultiMacAggregationFlag.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MultiMacAggregationFlag.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MultiMacAggregationFlag.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MultiMacAggregationFlag.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NoopDeliveredNotificationClearer.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackingUp.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackingUp.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackingUp.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackingUp.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClient.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRecord.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRecord.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRecord.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRecord.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRoute.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRoute.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRoute.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupFailableRoute.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupListResponse.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupListResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupListResponse.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupListResponse.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOp.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOp.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOp.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOp.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOpWire.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOpWire.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOpWire.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupOpWire.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecord.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecord.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecord.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecord.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecordWire.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecordWire.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecordWire.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRecordWire.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRefreshing.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRefreshing.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRefreshing.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRefreshing.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRequestBody.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRequestBody.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRequestBody.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRequestBody.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacForgottenStoring.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacForgottenStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacForgottenStoring.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacForgottenStoring.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacMutationGate.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacMutationGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacMutationGate.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacMutationGate.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacPendingDeleteStoring.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacPendingDeleteStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacPendingDeleteStoring.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacPendingDeleteStoring.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestoreBoundary.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestoreBoundary.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestoreBoundary.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacRestoreBoundary.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PendingNotificationDismissQueue.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClientError.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClientError.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClientError.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClientError.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceDevice.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceDevice.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceDevice.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceDevice.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceInstance.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceInstance.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceInstance.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceInstance.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceMap.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceOfflineReason.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceOfflineReason.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceOfflineReason.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceOfflineReason.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceServiceConfiguration.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSubscribing.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSubscribing.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSubscribing.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceSubscribing.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceTokenSource.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceTokenSource.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceTokenSource.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceTokenSource.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceUpdate.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceUpdate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceUpdate.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceUpdate.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/ResolveForegroundMacID.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/ResolveForegroundMacID.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/ResolveForegroundMacID.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/ResolveForegroundMacID.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/RestoreOutcome.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/RestoreOutcome.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/RestoreOutcome.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/RestoreOutcome.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientHandle.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacSubscription.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SystemDeliveredNotificationClearer.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TeamScopedPairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalDraftStoring.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalDraftStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalDraftStoring.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalDraftStoring.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputDelivery.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalScrollDelivery.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalTextSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalTextSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalTextSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalTextSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacForgottenStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacForgottenStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacForgottenStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacForgottenStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacPendingDeleteStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacPendingDeleteStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacPendingDeleteStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacPendingDeleteStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceMutationTarget.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceMutationTarget.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceMutationTarget.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceMutationTarget.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AlwaysOnlineReachability.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/AlwaysOnlineReachability.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/AlwaysOnlineReachability.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/AlwaysOnlineReachability.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerDefaultOpenTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerDefaultOpenTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerDefaultOpenTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerDefaultOpenTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitDraftReconcileTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitDraftReconcileTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitDraftReconcileTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitDraftReconcileTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingDraftStore.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingDraftStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingDraftStore.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingDraftStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransportFactory.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransportFactory.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CountingSlowIgnoringCancellationTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamDeviceRegistry.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamDeviceRegistry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamDeviceRegistry.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamDeviceRegistry.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedTeamPairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayingDraftStore.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayingDraftStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayingDraftStore.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayingDraftStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryListParsingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRouteSelectionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftPipelineOrderingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftPipelineOrderingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftPipelineOrderingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftPipelineOrderingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftSwitchOwnershipTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftSwitchOwnershipTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftSwitchOwnershipTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DraftSwitchOwnershipTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FakeBackup.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/FakeBackup.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FakeBackup.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/FakeBackup.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/GatedUpsertStore.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/GatedUpsertStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/GatedUpsertStore.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/GatedUpsertStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IOSBuildScopedPairedMacStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryTerminalDraftStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryTerminalDraftStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryTerminalDraftStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryTerminalDraftStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessHostRouterAttachTicketResponse.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessHostRouterAttachTicketResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessHostRouterAttachTicketResponse.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessHostRouterAttachTicketResponse.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessViewportReport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessViewportReport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessViewportReport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessViewportReport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MacBuildChannelTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MacBuildChannelTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MacBuildChannelTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MacBuildChannelTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileFeedbackEmailClientTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileFeedbackEmailClientTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileFeedbackEmailClientTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileFeedbackEmailClientTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceConnectionAuthorityTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceRouteRaceTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceRouteRaceTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceRouteRaceTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceRouteRaceTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceTagAuthorityTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceTagAuthorityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceTagAuthorityTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacInstanceTagAuthorityTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingAttemptDeadlineTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingAttemptDeadlineTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingAttemptDeadlineTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingAttemptDeadlineTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairingFailureTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSecondaryInstanceAuthorityTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSecondaryInstanceAuthorityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSecondaryInstanceAuthorityTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSecondaryInstanceAuthorityTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellColdAttachReplayBarrierTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellColdAttachReplayBarrierTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellColdAttachReplayBarrierTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellColdAttachReplayBarrierTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeAgentChatCacheTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeAgentChatCacheTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeAgentChatCacheTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeAgentChatCacheTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeColorSlotResetTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeColorSlotResetTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeColorSlotResetTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeColorSlotResetTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetMacTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetMacTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetMacTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetMacTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgottenMacRefreshTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgottenMacRefreshTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgottenMacRefreshTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgottenMacRefreshTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairedMacCoalescingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairedMacCoalescingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairedMacCoalescingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairedMacCoalescingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePreviewTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDismissSyncTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundResumeTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundResumeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundResumeTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellForegroundResumeTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridEventFrameFixtures.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridEventFrameFixtures.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridEventFrameFixtures.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridEventFrameFixtures.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridFrameTestSupport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridFrameTestSupport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridFrameTestSupport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridFrameTestSupport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridInputCatchUpTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessRuntimeSupport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessRuntimeSupport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessRuntimeSupport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessRuntimeSupport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayRetryExhaustionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayRetryExhaustionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayRetryExhaustionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayRetryExhaustionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayStalenessTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayStalenessTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayStalenessTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridReplayStalenessTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayDropRecoveryTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayDropRecoveryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayDropRecoveryTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayDropRecoveryTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayFallbackScreenTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayFallbackScreenTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayFallbackScreenTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellReplayFallbackScreenTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCreateTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MultiMacAggregationFlagTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MultiMacAggregationFlagTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MultiMacAggregationFlagTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MultiMacAggregationFlagTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableBackup.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableBackup.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableBackup.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableBackup.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeam.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeam.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeam.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeam.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeamID.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeamID.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeamID.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MutableTeamID.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacInstanceTagBackupTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacInstanceTagBackupTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacInstanceTagBackupTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacInstanceTagBackupTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairingDeadlineRuntime.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairingDeadlineRuntime.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairingDeadlineRuntime.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairingDeadlineRuntime.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PendingNotificationDismissQueueTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceMapTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceServiceURLResolutionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceServiceURLResolutionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceServiceURLResolutionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceServiceURLResolutionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceWireParsingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceWireParsingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceWireParsingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceWireParsingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/RecordingDeliveredNotificationClearer.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ResolveForegroundMacIDTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ResolveForegroundMacIDTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ResolveForegroundMacIDTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ResolveForegroundMacIDTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransportFactory.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransportFactory.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SlowIgnoringCancellationTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/StaticIdentityProvider.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/StaticIdentityProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/StaticIdentityProvider.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/StaticIdentityProvider.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildPresenceRouteIsolationTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TaggedBuildRegistryRouteIsolationTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TeamScopedPairedMacStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TeamScopedPairedMacStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TeamScopedPairedMacStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TeamScopedPairedMacStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalByteGapRebaseTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalByteGapRebaseTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalByteGapRebaseTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalByteGapRebaseTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalColdAttachReplayBarrierTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalColdAttachReplayBarrierTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalColdAttachReplayBarrierTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalColdAttachReplayBarrierTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalMissingBaselineReplayBudgetTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalMissingBaselineReplayBudgetTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalMissingBaselineReplayBudgetTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalMissingBaselineReplayBudgetTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierAckResetTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierAckResetTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierAckResetTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierAckResetTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierFollowUpTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierFollowUpTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierFollowUpTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayBarrierFollowUpTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayOutputQueueTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayOutputQueueTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayOutputQueueTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReplayOutputQueueTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFloorByteChannelTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFloorByteChannelTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFloorByteChannelTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFloorByteChannelTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFrameRecoveryBarrierTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFrameRecoveryBarrierTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFrameRecoveryBarrierTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalStaleFrameRecoveryBarrierTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalTextSnapshotTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalTextSnapshotTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalTextSnapshotTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalTextSnapshotTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeApplyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeApplyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeApplyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeApplyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalViewportResyncTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TokenProbe.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TokenProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TokenProbe.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TokenProbe.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Package.swift b/Packages/Shared/CmuxMobileShellModel/Package.swift similarity index 95% rename from Packages/iOS/CmuxMobileShellModel/Package.swift rename to Packages/Shared/CmuxMobileShellModel/Package.swift index 7b26e264dd34..611e58cdb352 100644 --- a/Packages/iOS/CmuxMobileShellModel/Package.swift +++ b/Packages/Shared/CmuxMobileShellModel/Package.swift @@ -15,7 +15,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CMUXMobileCore"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacWorkspaceState.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacWorkspaceState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacWorkspaceState.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacWorkspaceState.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MachineAvatarPalette.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MachineAvatarPalette.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MachineAvatarPalette.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MachineAvatarPalette.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileBuildType.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileClientIDRepository.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileClientIDRepository.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileClientIDRepository.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileClientIDRepository.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionState.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionState.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionState.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackRoute.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackRoute.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackRoute.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackRoute.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackStamp.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackStamp.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackStamp.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileFeedbackStamp.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileIdentityProviding.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileIdentityProviding.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileIdentityProviding.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileIdentityProviding.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacConnectionStatus.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacConnectionStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacConnectionStatus.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacConnectionStatus.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingStore.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePairingURLConnectionResult.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePendingAttachment.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileRelativeActivity.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileRelativeActivity.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileRelativeActivity.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileRelativeActivity.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellPhase.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellPhase.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellPhase.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellPhase.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputEnqueueResult.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputEnqueueResult.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputEnqueueResult.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputEnqueueResult.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputSendBuffer.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputSendBuffer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputSendBuffer.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalInputSendBuffer.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalOutputSinking.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalPreview.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportFit.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportFit.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportFit.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportFit.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportSize.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportSize.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportSize.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalViewportSize.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceActionCapabilities.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceAggregation.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceAggregation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceAggregation.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceAggregation.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupCollapseStore.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupCollapseStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupCollapseStore.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupCollapseStore.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupPreview.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListFilter.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListFilter.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListFilter.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListFilter.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem+MoveIntent.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListItem.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceListMoveIntentResolver.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMoveIntent.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMoveIntent.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMoveIntent.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMoveIntent.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceMovePolicy.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrder.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderEntry.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOptimisticOrderReconciler.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderMoveApplier.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceOrderSignature.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview+WindowScope.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview+WindowScope.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview+WindowScope.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview+WindowScope.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspacePreview.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceReadStateFilter.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceReadStateFilter.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceReadStateFilter.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceReadStateFilter.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MachineAvatarPaletteTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MachineAvatarPaletteTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MachineAvatarPaletteTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MachineAvatarPaletteTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileClientIDRepositoryTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileClientIDRepositoryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileClientIDRepositoryTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileClientIDRepositoryTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileFeedbackRouteTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileOnboardingStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileRelativeActivityTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileRelativeActivityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileRelativeActivityTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileRelativeActivityTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalInputSendBufferTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalInputSendBufferTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalInputSendBufferTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalInputSendBufferTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalViewportFitTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalViewportFitTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalViewportFitTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTerminalViewportFitTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceAggregationTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceAggregationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceAggregationTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceAggregationTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupCollapseStoreTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupCollapseStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupCollapseStoreTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupCollapseStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceHostOrderSimulator.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListFilterTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListFilterTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListFilterTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListFilterTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemMoveEdgeTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceListItemTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyParityTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceMovePolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceOptimisticOrderTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceWindowScopeTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceWindowScopeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceWindowScopeTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceWindowScopeTests.swift diff --git a/Packages/iOS/CmuxMobileSupport/Package.swift b/Packages/Shared/CmuxMobileSupport/Package.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Package.swift rename to Packages/Shared/CmuxMobileSupport/Package.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/AppVersionInfo.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/AppVersionInfo.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/AppVersionInfo.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/AppVersionInfo.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationAudioEngine.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationAudioEngine.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationAudioEngine.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationAudioEngine.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationStartDisposition.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationStartDisposition.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationStartDisposition.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationStartDisposition.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+RefreshingTerminalMenuPreview.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+RefreshingTerminalMenuPreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+RefreshingTerminalMenuPreview.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+RefreshingTerminalMenuPreview.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileCompactToolbarTitleStack.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconButton.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconButton.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconButton.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconButton.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardReservation.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardReservation.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardReservation.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardReservation.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibility.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibility.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibility.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibility.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/AppVersionInfoTests.swift b/Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/AppVersionInfoTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/AppVersionInfoTests.swift rename to Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/AppVersionInfoTests.swift diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/ComposerDictationTests.swift b/Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/ComposerDictationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/ComposerDictationTests.swift rename to Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/ComposerDictationTests.swift diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/L10nTests.swift b/Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/L10nTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/L10nTests.swift rename to Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/L10nTests.swift diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift b/Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift rename to Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift b/Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift rename to Packages/Shared/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Package.swift b/Packages/Shared/CmuxMobileTerminalKit/Package.swift similarity index 95% rename from Packages/iOS/CmuxMobileTerminalKit/Package.swift rename to Packages/Shared/CmuxMobileTerminalKit/Package.swift index eb41bc8fc368..2409171236c7 100644 --- a/Packages/iOS/CmuxMobileTerminalKit/Package.swift +++ b/Packages/Shared/CmuxMobileTerminalKit/Package.swift @@ -15,7 +15,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CMUXMobileCore"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/CustomToolbarAction.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalAccessoryLayoutReducer.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalArrowRepeatService.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalArrowRepeatService.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalArrowRepeatService.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalArrowRepeatService.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalColumnReportWidthSelection.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalColumnReportWidthSelection.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalColumnReportWidthSelection.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalColumnReportWidthSelection.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorBlinkState.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorBlinkState.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorBlinkState.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorBlinkState.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalFontZoomDirection.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalFontZoomDirection.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalFontZoomDirection.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalFontZoomDirection.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifier.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifier.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifier.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifier.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifierState.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifierState.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifierState.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputModifierState.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier+Codable.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier+Codable.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier+Codable.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier+Codable.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyModifier.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalRowCapacityFit.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalRowCapacityFit.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalRowCapacityFit.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalRowCapacityFit.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSpecialKey.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSpecialKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSpecialKey.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalSpecialKey.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarActionPayload.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarItemID.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/ToolbarLayoutMigration.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalAccessoryLayoutReducerTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalArrowRepeatServiceTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalArrowRepeatServiceTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalArrowRepeatServiceTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalArrowRepeatServiceTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputModifierStateTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputModifierStateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputModifierStateTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputModifierStateTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyEncoderTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyEncoderTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyEncoderTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyEncoderTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalLetterboxGeometryTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalLetterboxGeometryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalLetterboxGeometryTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalLetterboxGeometryTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalRowCapacityFitTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalRowCapacityFitTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalRowCapacityFitTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalRowCapacityFitTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalValueTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalValueTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalValueTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalValueTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarCustomizationTests.swift diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarLayoutMigrationFoldTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarLayoutMigrationFoldTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarLayoutMigrationFoldTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ToolbarLayoutMigrationFoldTests.swift diff --git a/Packages/iOS/CmuxMobileTransport/Package.swift b/Packages/Shared/CmuxMobileTransport/Package.swift similarity index 95% rename from Packages/iOS/CmuxMobileTransport/Package.swift rename to Packages/Shared/CmuxMobileTransport/Package.swift index f9ab601955e7..168ff5755afe 100644 --- a/Packages/iOS/CmuxMobileTransport/Package.swift +++ b/Packages/Shared/CmuxMobileTransport/Package.swift @@ -15,7 +15,7 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CMUXMobileCore"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkRoutePinger.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkRoutePinger.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkRoutePinger.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkRoutePinger.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddress.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddress.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddress.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddress.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddressProviding.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddressProviding.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddressProviding.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkInterfaceAddressProviding.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkReachability.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkReachability.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkReachability.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/NetworkReachability.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityProviding.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityProviding.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityProviding.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityProviding.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityService.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityService.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityService.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/ReachabilityService.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/SystemNetworkInterfaceAddressProvider.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/SystemNetworkInterfaceAddressProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/SystemNetworkInterfaceAddressProvider.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/SystemNetworkInterfaceAddressProvider.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatus.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatus.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatus.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatusMonitor.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatusMonitor.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatusMonitor.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/TailscaleStatusMonitor.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportTests.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxRoutePingTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxRoutePingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxRoutePingTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxRoutePingTests.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/PingTestListener.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/PingTestListener.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/PingTestListener.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/PingTestListener.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ReachabilityServiceTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ReachabilityServiceTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ReachabilityServiceTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ReachabilityServiceTests.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/TailscaleStatusTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/TailscaleStatusTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/TailscaleStatusTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/TailscaleStatusTests.swift diff --git a/Packages/Shared/CmuxSyncStore/Package.swift b/Packages/Shared/CmuxSyncStore/Package.swift index dba45160ddc1..1e04ec08b898 100644 --- a/Packages/Shared/CmuxSyncStore/Package.swift +++ b/Packages/Shared/CmuxSyncStore/Package.swift @@ -16,8 +16,8 @@ let package = Package( ], dependencies: [ .package(path: "../CMUXMobileCore"), - .package(path: "../../iOS/CmuxMobilePairedMac"), - .package(path: "../../iOS/CmuxMobileShellModel"), + .package(path: "../CmuxMobilePairedMac"), + .package(path: "../CmuxMobileShellModel"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxAgentChatUI/Package.swift b/Packages/iOS/CmuxAgentChatUI/Package.swift index b9629130545d..930393611e8f 100644 --- a/Packages/iOS/CmuxAgentChatUI/Package.swift +++ b/Packages/iOS/CmuxAgentChatUI/Package.swift @@ -17,7 +17,7 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CmuxAgentChat"), - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileSupport"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobileBrowser/Package.swift b/Packages/iOS/CmuxMobileBrowser/Package.swift index 150d165afce4..2641cf147c98 100644 --- a/Packages/iOS/CmuxMobileBrowser/Package.swift +++ b/Packages/iOS/CmuxMobileBrowser/Package.swift @@ -17,7 +17,7 @@ let package = Package( dependencies: [ // Localized-string helpers (`L10n`). `CmuxMobileSupport` is a leaf with // no dependencies, so the browser package stays low in the DAG. - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileSupport"), ], targets: [ // A self-contained, phone-local browser surface. P1 browser state never diff --git a/Packages/iOS/CmuxMobileShellUI/Package.swift b/Packages/iOS/CmuxMobileShellUI/Package.swift index 0c5a93c828b0..968ed8143ff6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.swift +++ b/Packages/iOS/CmuxMobileShellUI/Package.swift @@ -21,13 +21,13 @@ let package = Package( .package(path: "../../Shared/CmuxAuthRuntime"), .package(path: "../CmuxMobileBrowser"), .package(path: "../CmuxMobileCamera"), - .package(path: "../CmuxMobileDiagnostics"), - .package(path: "../CmuxMobilePairedMac"), - .package(path: "../CmuxMobileShell"), - .package(path: "../CmuxMobileShellModel"), - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileDiagnostics"), + .package(path: "../../Shared/CmuxMobilePairedMac"), + .package(path: "../../Shared/CmuxMobileShell"), + .package(path: "../../Shared/CmuxMobileShellModel"), + .package(path: "../../Shared/CmuxMobileSupport"), .package(path: "../CmuxMobileTerminal"), - .package(path: "../CmuxMobileTerminalKit"), + .package(path: "../../Shared/CmuxMobileTerminalKit"), .package(path: "../CmuxMobileWorkspace"), .package(path: "../../../vendor/stack-auth-swift-sdk-prerelease"), ], diff --git a/Packages/iOS/CmuxMobileTerminal/Package.swift b/Packages/iOS/CmuxMobileTerminal/Package.swift index 41a8453b1c8a..238708fe67c9 100644 --- a/Packages/iOS/CmuxMobileTerminal/Package.swift +++ b/Packages/iOS/CmuxMobileTerminal/Package.swift @@ -16,9 +16,9 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), .package(path: "../../Shared/CmuxAgentChat"), - .package(path: "../CmuxMobileDiagnostics"), - .package(path: "../CmuxMobileSupport"), - .package(path: "../CmuxMobileTerminalKit"), + .package(path: "../../Shared/CmuxMobileDiagnostics"), + .package(path: "../../Shared/CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileTerminalKit"), ], targets: [ // The same libghostty the Mac links; iOS feeds raw PTY bytes straight diff --git a/Packages/iOS/CmuxMobileWorkspace/Package.swift b/Packages/iOS/CmuxMobileWorkspace/Package.swift index ad96c5ca4734..ed2220edabaf 100644 --- a/Packages/iOS/CmuxMobileWorkspace/Package.swift +++ b/Packages/iOS/CmuxMobileWorkspace/Package.swift @@ -16,7 +16,7 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../CmuxMobileShellModel"), + .package(path: "../../Shared/CmuxMobileShellModel"), ], targets: [ .target( diff --git a/Packages/macOS/CmuxHive/Package.swift b/Packages/macOS/CmuxHive/Package.swift index 5bf5b792ca53..dfb611beefd7 100644 --- a/Packages/macOS/CmuxHive/Package.swift +++ b/Packages/macOS/CmuxHive/Package.swift @@ -15,12 +15,12 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../../iOS/CmuxMobilePairedMac"), - .package(path: "../../iOS/CmuxMobileRPC"), - .package(path: "../../iOS/CmuxMobileShell"), - .package(path: "../../iOS/CmuxMobileShellModel"), - .package(path: "../../iOS/CmuxMobileTerminalKit"), - .package(path: "../../iOS/CmuxMobileTransport"), + .package(path: "../../Shared/CmuxMobilePairedMac"), + .package(path: "../../Shared/CmuxMobileRPC"), + .package(path: "../../Shared/CmuxMobileShell"), + .package(path: "../../Shared/CmuxMobileShellModel"), + .package(path: "../../Shared/CmuxMobileTerminalKit"), + .package(path: "../../Shared/CmuxMobileTransport"), ], targets: [ .target( diff --git a/Packages/macOS/CmuxHiveUI/Package.swift b/Packages/macOS/CmuxHiveUI/Package.swift index 4644f12dadb6..b7972e72cd5b 100644 --- a/Packages/macOS/CmuxHiveUI/Package.swift +++ b/Packages/macOS/CmuxHiveUI/Package.swift @@ -16,7 +16,7 @@ let package = Package( dependencies: [ .package(path: "../CmuxHive"), .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../../iOS/CmuxMobileTerminalKit"), + .package(path: "../../Shared/CmuxMobileTerminalKit"), ], targets: [ .target( diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index 2a85cf4f3923..6e90da04580d 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -29,59 +29,59 @@ location = "group:CMUXMobileCore"> + location = "group:CmuxMobileDiagnostics"> - - + location = "group:CmuxMobilePairedMac"> + location = "group:CmuxMobileRPC"> + location = "group:CmuxMobileShell"> + location = "group:CmuxMobileShellModel"> + location = "group:CmuxMobileSupport"> + location = "group:CmuxMobileTerminalKit"> + location = "group:CmuxMobileTransport"> + location = "group:CmuxSyncStore"> + + + location = "container:ios/cmuxPackage"> + location = "group:CmuxAgentChatUI"> + location = "group:CmuxMobileAnalytics"> + location = "group:CmuxMobileBrowser"> + location = "group:CmuxMobileCamera"> + location = "group:CmuxMobileCrashReporting"> + location = "group:CmuxMobileShellUI"> + location = "group:CmuxMobileTerminal"> diff --git a/docs/presence-service.md b/docs/presence-service.md index b246b03e8ba6..580b383b7921 100644 --- a/docs/presence-service.md +++ b/docs/presence-service.md @@ -160,7 +160,7 @@ the first production deploy and dogfood. tag, best-effort, never disturbs the Mac. Every beat carries the full current attach-route set, a route change triggers one immediate out-of-cadence beat, and a clean quit sends a goodbye. -- **iOS** (`Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift`): +- **iOS** (`Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift`): typed WebSocket subscribe client. `MobileShellComposite` owns the subscription (starts on sign-in, blanks and stops on sign-out, backoff reconnect with snapshot-first resync), reduces frames into `PresenceMap`, diff --git a/ios/cmuxPackage/Package.swift b/ios/cmuxPackage/Package.swift index c88d828c610e..6ae97ee7fb33 100644 --- a/ios/cmuxPackage/Package.swift +++ b/ios/cmuxPackage/Package.swift @@ -29,16 +29,16 @@ let package = Package( .package(path: "../../Packages/iOS/CmuxMobileBrowser"), .package(path: "../../Packages/iOS/CmuxMobileCamera"), .package(path: "../../Packages/iOS/CmuxMobileCrashReporting"), - .package(path: "../../Packages/iOS/CmuxMobileDiagnostics"), - .package(path: "../../Packages/iOS/CmuxMobilePairedMac"), - .package(path: "../../Packages/iOS/CmuxMobileRPC"), - .package(path: "../../Packages/iOS/CmuxMobileShell"), - .package(path: "../../Packages/iOS/CmuxMobileShellModel"), + .package(path: "../../Packages/Shared/CmuxMobileDiagnostics"), + .package(path: "../../Packages/Shared/CmuxMobilePairedMac"), + .package(path: "../../Packages/Shared/CmuxMobileRPC"), + .package(path: "../../Packages/Shared/CmuxMobileShell"), + .package(path: "../../Packages/Shared/CmuxMobileShellModel"), .package(path: "../../Packages/iOS/CmuxMobileShellUI"), - .package(path: "../../Packages/iOS/CmuxMobileSupport"), + .package(path: "../../Packages/Shared/CmuxMobileSupport"), .package(path: "../../Packages/iOS/CmuxMobileTerminal"), - .package(path: "../../Packages/iOS/CmuxMobileTerminalKit"), - .package(path: "../../Packages/iOS/CmuxMobileTransport"), + .package(path: "../../Packages/Shared/CmuxMobileTerminalKit"), + .package(path: "../../Packages/Shared/CmuxMobileTransport"), .package(path: "../../Packages/iOS/CmuxMobileWorkspace"), .package(path: "../../vendor/stack-auth-swift-sdk-prerelease"), ], diff --git a/plans/feat-ios-iroh/DESIGN.md b/plans/feat-ios-iroh/DESIGN.md index 4ff5ba5e9b5a..a3c9481ed90d 100644 --- a/plans/feat-ios-iroh/DESIGN.md +++ b/plans/feat-ios-iroh/DESIGN.md @@ -10,7 +10,7 @@ The codebase already reserved the seams for this: - `CmxAttachTransportKind.iroh` exists and validates against `.peer` endpoints (`Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxTransport.swift`). - `CmxAttachEndpoint.peer(id:relayHint:directAddrs:relayURL:)` already carries exactly what an iroh dial needs. -- `MobileShellRouteAuthPolicy` already classifies `(.iroh, .peer)` as an encrypted route that may carry Stack tokens (`Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift`). +- `MobileShellRouteAuthPolicy` already classifies `(.iroh, .peer)` as an encrypted route that may carry Stack tokens (`Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift`). - The phone builds transports through `CmxRouteTransportFactory` keyed by route kind; adding a kind is one registration in `ios/cmux/cmuxApp.swift`. - The device registry (merged, https://github.com/manaflow-ai/cmux/pull/5626) stores `CmxAttachRoute` lists as opaque jsonb, so an `iroh` route needs zero schema change, and the phone's `DeviceRegistryService` already skips unknown route kinds on old builds (forward compatible). - Settings diagnostics already have a localized "Iroh" route label (`Sources/HostSettingsActions.swift`). diff --git a/scripts/lint-namespace-types-baseline.txt b/scripts/lint-namespace-types-baseline.txt index 2d72fe0b936a..44b5ab151d48 100644 --- a/scripts/lint-namespace-types-baseline.txt +++ b/scripts/lint-namespace-types-baseline.txt @@ -27,16 +27,16 @@ Packages/macOS/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CMUXSidebarExte Packages/macOS/CmuxExtensionKit/Sources/CmuxExtensionKit/Sidebar/CMUXSidebarXPC.swift:CmuxSidebarXPCCodec Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ScrubberDenylists.swift:ScrubberDenylists Packages/iOS/CmuxMobileBrowser/Sources/CmuxMobileBrowser/BrowserURLResolver.swift:BrowserURLResolver -Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift:CmxAttachTicketInput -Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift:PreviewMobileHost -Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift:MobileShellRouteAuthPolicy -Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift:L10n -Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift:UITestConfig +Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/CmxAttachTicketInput.swift:CmxAttachTicketInput +Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/PreviewMobileHost.swift:PreviewMobileHost +Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift:MobileShellRouteAuthPolicy +Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/L10n.swift:L10n +Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift:UITestConfig Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/MobileTerminalFontPreference.swift:MobileTerminalFontPreference -Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift:TerminalDECTCEMCursorScanner -Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift:TerminalKeyEncoder -Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift:TerminalLetterboxGeometry -Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift:TerminalTextInputPipeline +Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalDECTCEMCursorScanner.swift:TerminalDECTCEMCursorScanner +Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyEncoder.swift:TerminalKeyEncoder +Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalLetterboxGeometry.swift:TerminalLetterboxGeometry +Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalTextInputPipeline.swift:TerminalTextInputPipeline Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileOnboardingGate.swift:MobileOnboardingGate Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobilePairingScannerPolicy.swift:MobilePairingScannerPolicy Packages/iOS/CmuxMobileWorkspace/Sources/CmuxMobileWorkspace/MobileRootAuthGate.swift:MobileRootAuthGate From 7fd3089dd8a7a388e7f3a74114fa6a9d2d6cb0e5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 12:55:31 -0700 Subject: [PATCH 15/97] Computers: replace pairing link with a 6-digit code, drop Refresh List MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pairing link can't be carried between two Macs by hand. Replace it with a registry-rendezvous code: 'Pair This Mac' mints a 6-digit code (10-minute TTL) and advertises it as instance labels on this Mac's own /api/devices registration; 'Pair with Code' on the other Mac re-fetches the registry and pairs with the unique unexpired match, persisting that instance's advertised routes — same path as row pairing. The code is a rendezvous selector, not a bearer secret: registry rows (routes included) are already team-visible and the RPC still enforces account identity. No backend changes: instanceLabels already round-trip through POST/GET. - CmxPairingCode (CMUXMobileCore): mint, wire labels, expiry-checked claim parse, 6-digit input normalization - RegistryAppInstance parses instance labels; DeviceRegistryClient publishes the code and re-registers when it changes or expires - HiveComputerDirectory.pair(code:) with codeNotFound outcome; ambiguous duplicates and this Mac's own code are refused - Settings > Computers: code entry + live-countdown code display, links removed; Refresh List row replaced by a 30s auto-refresh while visible - en + ja strings updated; obsolete link/refresh keys removed Co-Authored-By: Claude Fable 5 --- .../CMUXMobileCore/CmxPairingCode.swift | 116 ++++++++ .../CmxPairingCodeTests.swift | 67 +++++ .../DeviceRegistryService.swift | 10 +- .../CmuxMobileShellModel/RegistryDevice.swift | 21 +- .../CmuxHive/HiveComputerDirectory.swift | 34 +++ .../Sources/CmuxHive/HivePairOutcome.swift | 3 + .../HiveComputerDirectoryTests.swift | 101 +++++++ .../ComputersSettingsSnapshot.swift | 18 +- .../Environment/SettingsHostActions.swift | 18 +- .../Sections/ComputersSection.swift | 194 +++++++------ Resources/Localizable.xcstrings | 263 +++++++----------- Sources/Cloud/DeviceRegistryClient.swift | 56 +++- Sources/HostSettingsActions+Computers.swift | 62 +---- cmuxTests/DeviceRegistryClientTests.swift | 29 +- 14 files changed, 677 insertions(+), 315 deletions(-) create mode 100644 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift create mode 100644 Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift new file mode 100644 index 000000000000..f01c1913e20a --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift @@ -0,0 +1,116 @@ +public import Foundation + +/// A short-lived 6-digit Mac-to-Mac pairing code advertised through the team +/// device registry. +/// +/// The host Mac publishes the code (and its expiry) as instance labels on its +/// own `/api/devices` registration; another Mac claims it by typing the code, +/// matching it against the registry rows it can already see, and pairing with +/// the matching instance's advertised routes. The code is a rendezvous +/// selector, not a bearer secret: registry rows (including routes) are already +/// team-visible, and the pairing RPC still enforces account identity. +/// +/// ```swift +/// let minted = CmxPairingCode(code: "042117", expiresAt: expiry) +/// body["instanceLabels"] = minted.instanceLabels +/// // …on the claiming Mac: +/// let active = CmxPairingCode.active(in: instance.labels, now: Date()) +/// ``` +public struct CmxPairingCode: Equatable, Sendable { + /// Instance label carrying the 6-digit code. + public static let codeLabelKey = "pairing_code" + /// Instance label carrying the code's ISO 8601 expiry timestamp. + public static let expiresAtLabelKey = "pairing_code_expires_at" + + /// The 6-digit, zero-padded code the user reads off the host Mac. + public var code: String + /// When the code stops being claimable. + public var expiresAt: Date + + /// Creates a pairing code value. + /// - Parameters: + /// - code: The 6-digit, zero-padded code string. + /// - expiresAt: When the code stops being claimable. + public init(code: String, expiresAt: Date) { + self.code = code + self.expiresAt = expiresAt + } + + /// Generates a fresh 6-digit code (zero-padded, `000000`–`999999`) + /// expiring `ttl` from `now`. + /// + /// - Parameters: + /// - ttl: Code lifetime. + /// - now: Mint-side clock, injected for testability. + /// - generator: Randomness source, injected for testability. + /// - Returns: The minted code value. + public static func minted( + ttl: TimeInterval, + now: Date, + using generator: inout some RandomNumberGenerator + ) -> CmxPairingCode { + CmxPairingCode( + code: String(format: "%06d", Int.random(in: 0...999_999, using: &generator)), + expiresAt: now.addingTimeInterval(ttl) + ) + } + + /// The wire form: the instance labels a registration POST advertises. + public var instanceLabels: [String: String] { + [ + Self.codeLabelKey: code, + Self.expiresAtLabelKey: Self.iso8601.string(from: expiresAt), + ] + } + + /// Decodes an unexpired pairing code from registry instance labels. + /// + /// A missing or unparseable expiry yields `nil` — a claim must never + /// succeed against a code whose lifetime cannot be verified. + /// + /// - Parameters: + /// - labels: The instance's string labels as returned by the registry. + /// - now: The claim-side clock, injected for testability. + /// - Returns: The active code, or `nil` when absent or expired. + public static func active(in labels: [String: String], now: Date) -> CmxPairingCode? { + guard + let code = labels[codeLabelKey]?.trimmingCharacters(in: .whitespacesAndNewlines), + !code.isEmpty, + let rawExpiry = labels[expiresAtLabelKey], + let expiresAt = parseISO8601(rawExpiry), + expiresAt > now + else { return nil } + return CmxPairingCode(code: code, expiresAt: expiresAt) + } + + /// The user-typed claim input normalized for matching: digits only, so + /// `"042 117"` and `"042117"` claim the same code. + /// - Parameter rawInput: The text the user typed. + /// - Returns: The digit string, or `nil` unless exactly 6 digits remain. + public static func normalizedClaimInput(_ rawInput: String) -> String? { + let digits = rawInput.filter(\.isNumber) + return digits.count == 6 ? digits : nil + } + + /// Encoder for the expiry label; fractional seconds match the registry's + /// own `toISOString()` timestamps. + private static var iso8601: ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + /// Lenient ISO 8601 parse (fractional and whole-second forms). Formatters + /// are created per call so the type stays `Sendable`-clean; this runs only + /// on label reads, never in a hot path. + private static func parseISO8601(_ raw: String) -> Date? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: trimmed) { return date } + let whole = ISO8601DateFormatter() + whole.formatOptions = [.withInternetDateTime] + return whole.date(from: trimmed) + } +} diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift new file mode 100644 index 000000000000..c07b87b80e5d --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import CMUXMobileCore + +/// Deterministic generator so minted codes are reproducible in tests. +private struct SeededGenerator: RandomNumberGenerator { + var state: UInt64 + mutating func next() -> UInt64 { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return state + } +} + +struct CmxPairingCodeTests { + private let now = Date(timeIntervalSince1970: 1_000) + + @Test func mintedProducesSixDigitZeroPaddedCodeAndTTLExpiry() { + var generator = SeededGenerator(state: 7) + let minted = CmxPairingCode.minted(ttl: 600, now: now, using: &generator) + #expect(minted.code.count == 6) + let allDigits = minted.code.allSatisfy(\.isNumber) + #expect(allDigits) + #expect(minted.expiresAt == now.addingTimeInterval(600)) + } + + @Test func instanceLabelsRoundTripThroughActive() { + let minted = CmxPairingCode(code: "042117", expiresAt: now.addingTimeInterval(600)) + let decoded = CmxPairingCode.active(in: minted.instanceLabels, now: now) + #expect(decoded == minted) + } + + @Test func activeRejectsExpiredMissingOrUnparseableExpiry() { + let expired = CmxPairingCode(code: "042117", expiresAt: now.addingTimeInterval(-1)) + #expect(CmxPairingCode.active(in: expired.instanceLabels, now: now) == nil) + #expect(CmxPairingCode.active( + in: [CmxPairingCode.codeLabelKey: "042117"], + now: now + ) == nil) + #expect(CmxPairingCode.active( + in: [ + CmxPairingCode.codeLabelKey: "042117", + CmxPairingCode.expiresAtLabelKey: "not-a-date", + ], + now: now + ) == nil) + #expect(CmxPairingCode.active(in: [:], now: now) == nil) + } + + @Test func activeAcceptsWholeSecondExpiryForm() { + let labels = [ + CmxPairingCode.codeLabelKey: "042117", + CmxPairingCode.expiresAtLabelKey: "1970-01-01T00:33:20Z", + ] + let decoded = CmxPairingCode.active(in: labels, now: now) + #expect(decoded?.code == "042117") + #expect(decoded?.expiresAt == Date(timeIntervalSince1970: 2_000)) + } + + @Test func normalizedClaimInputAcceptsExactlySixDigits() { + #expect(CmxPairingCode.normalizedClaimInput("042117") == "042117") + #expect(CmxPairingCode.normalizedClaimInput(" 042 117 ") == "042117") + #expect(CmxPairingCode.normalizedClaimInput("042-117") == "042117") + #expect(CmxPairingCode.normalizedClaimInput("42117") == nil) + #expect(CmxPairingCode.normalizedClaimInput("0421178") == nil) + #expect(CmxPairingCode.normalizedClaimInput("") == nil) + } +} diff --git a/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index 7ab91d5e5cb7..c12993610621 100644 --- a/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -220,10 +220,17 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { value = try? CmxAttachRoute(from: decoder) } } + struct FailableLabel: Decodable { + let value: String? + init(from decoder: any Decoder) throws { + value = try? decoder.singleValueContainer().decode(String.self) + } + } struct Instance: Decodable { let tag: String? let routes: [FailableRoute]? let lastSeenAt: String? + let labels: [String: FailableLabel]? } struct Device: Decodable { let deviceId: String @@ -246,7 +253,8 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { tag: instance.tag?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? instance.tag! : "default", routes: (instance.routes ?? []).compactMap(\.value), - lastSeenAt: Self.parseTimestamp(instance.lastSeenAt) + lastSeenAt: Self.parseTimestamp(instance.lastSeenAt), + labels: (instance.labels ?? [:]).compactMapValues(\.value) ) } return RegistryDevice( diff --git a/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift index 2d0fc78e9240..d40108083863 100644 --- a/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift +++ b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift @@ -19,19 +19,38 @@ public struct RegistryAppInstance: Equatable, Sendable, Identifiable { /// 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 + /// String-valued instance labels round-tripped through the registry + /// (`instanceLabels` on POST). Carries the Mac-to-Mac pairing-code claim + /// (see ``CmxPairingCode``); non-string label values are dropped at parse + /// time. + public var labels: [String: String] /// 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) { + public init( + tag: String, + routes: [CmxAttachRoute], + lastSeenAt: Date, + labels: [String: String] = [:] + ) { self.tag = tag self.routes = routes self.lastSeenAt = lastSeenAt + self.labels = labels } /// 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 } + + /// The instance's advertised Mac-to-Mac pairing code, when one is present + /// and unexpired at `now`. See ``CmxPairingCode/active(in:now:)``. + /// - Parameter now: The claim-side clock, injected for testability. + /// - Returns: The active code, or `nil` when absent or expired. + public func activePairingCode(now: Date) -> CmxPairingCode? { + CmxPairingCode.active(in: labels, now: now) + } } /// One registered physical machine (Mac/host) in the team-scoped device diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index 826b36d06114..c4aaeda02284 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -163,6 +163,40 @@ public final class HiveComputerDirectory { ) } + /// Pair from the 6-digit code another Mac's "Pair This Mac" row is + /// showing. + /// + /// The code is a registry rendezvous: the host advertises it (with an + /// expiry) in its instance labels, so the claim re-fetches the registry + /// and pairs with the unique instance whose unexpired code matches. + /// Non-digits in the input are ignored, so `"042 117"` claims `042117`. + /// + /// - Parameter rawCode: The user-typed code. + /// - Returns: The pair outcome; ambiguous or expired codes report + /// ``HivePairOutcome/codeNotFound``. + public func pair(code rawCode: String) async -> HivePairOutcome { + guard let code = CmxPairingCode.normalizedClaimInput(rawCode) else { + return .codeNotFound + } + await refresh() + let claimTime = now() + let matches = registryDevices.flatMap { device in + device.instances + .filter { $0.hasRoutes && $0.activePairingCode(now: claimTime)?.code == code } + .map { (device: device, instance: $0) } + } + // Exactly one live match may claim; a 6-digit collision inside one + // team is ambiguous and reports not-found rather than guessing. + guard matches.count == 1, let match = matches.first else { return .codeNotFound } + guard match.device.deviceId != ownDeviceID else { return .loopbackRejected } + return await persistPairing( + macDeviceID: match.device.deviceId, + displayName: match.device.displayName, + routes: match.instance.routes, + instanceTag: match.instance.tag + ) + } + /// Pair from a pasted pairing link (the QR payload another Mac shows). public func pair(link rawLink: String) async -> HivePairOutcome { let scope = await scopeProvider() diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift index 261b069c13ce..d888a7fe7e20 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift @@ -7,6 +7,9 @@ public enum HivePairOutcome: Equatable, Sendable { case paired(deviceID: String) /// The pasted pairing link / code could not be decoded. case invalidLink + /// No registry instance currently advertises the entered pairing code + /// (mistyped, expired, or the host stopped advertising it). + case codeNotFound /// The link decoded, but every route points back at this computer /// (loopback), which release builds refuse to dial. case loopbackRejected diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift index 19f77acb0400..92826fd7ef5d 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -367,3 +367,104 @@ private func makeDirectory( #expect(best.routes == [routeB]) } } + +/// pair(code:) — the registry-rendezvous claim for the 6-digit code another +/// Mac's "Pair This Mac" row shows. The directory's injected clock is epoch +/// 1000, so labels expiring at epoch 2000 are live and epoch 500 expired. +@Suite struct HiveComputerDirectoryPairByCodeTests { + private static let liveExpiry = "1970-01-01T00:33:20Z" // epoch 2000 + private static let pastExpiry = "1970-01-01T00:08:20Z" // epoch 500 + + private func codedDevice( + deviceId: String, + code: String, + expiry: String = Self.liveExpiry, + tag: String = "stable", + route: CmxAttachRoute + ) -> RegistryDevice { + RegistryDevice( + deviceId: deviceId, + platform: "mac", + displayName: "Coded Mac", + lastSeenAt: Date(timeIntervalSince1970: 950), + instances: [ + RegistryAppInstance( + tag: tag, + routes: [route], + lastSeenAt: Date(timeIntervalSince1970: 900), + labels: [ + CmxPairingCode.codeLabelKey: code, + CmxPairingCode.expiresAtLabelKey: expiry, + ] + ) + ] + ) + } + + @MainActor + @Test func pairByCodeClaimsTheUniqueLiveMatch() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let route = try tailscaleRoute() + let device = codedDevice(deviceId: "coded-mac", code: "042117", route: route) + let directory = makeDirectory(registry: .ok([device]), store: store) + + let outcome = await directory.pair(code: " 042 117 ") + #expect(outcome == .paired(deviceID: "coded-mac")) + + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.count == 1) + #expect(persisted.first?.macDeviceID == "coded-mac") + #expect(persisted.first?.routes == [route]) + #expect(persisted.first?.instanceTag == "stable") + } + + @MainActor + @Test func pairByCodeRejectsWrongExpiredAndMalformedCodes() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let live = codedDevice(deviceId: "coded-mac", code: "042117", route: try tailscaleRoute()) + let expired = codedDevice( + deviceId: "expired-mac", + code: "555555", + expiry: Self.pastExpiry, + route: try tailscaleRoute(host: "100.64.0.10") + ) + let directory = makeDirectory(registry: .ok([live, expired]), store: store) + + #expect(await directory.pair(code: "000000") == .codeNotFound) + #expect(await directory.pair(code: "555555") == .codeNotFound) + #expect(await directory.pair(code: "4211") == .codeNotFound) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.isEmpty) + } + + @MainActor + @Test func pairByCodeRefusesAmbiguousDuplicates() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let first = codedDevice(deviceId: "mac-a", code: "042117", route: try tailscaleRoute()) + let second = codedDevice( + deviceId: "mac-b", + code: "042117", + route: try tailscaleRoute(host: "100.64.0.11") + ) + let directory = makeDirectory(registry: .ok([first, second]), store: store) + + #expect(await directory.pair(code: "042117") == .codeNotFound) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.isEmpty) + } + + @MainActor + @Test func pairByCodeRejectsThisMacsOwnCode() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let own = codedDevice(deviceId: "self-device", code: "042117", route: try tailscaleRoute()) + let directory = makeDirectory(registry: .ok([own]), store: store) + + #expect(await directory.pair(code: "042117") == .loopbackRejected) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.isEmpty) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift index 5980093617a2..0fbed9dcea92 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift @@ -82,13 +82,16 @@ public struct ComputersSettingsSnapshot: Sendable, Equatable { } } -/// Result of a pair action (registry row or pasted link), rendered inline. +/// Result of a pair action (registry row or entered code), rendered inline. public enum ComputersPairResult: Sendable, Equatable { /// Paired; the row list refreshes via the snapshot stream. case paired /// The pasted text is not a cmux pairing link. case invalidLink - /// Every route in the link points back at this Mac. + /// No computer currently advertises the entered pairing code (mistyped, + /// expired, or the other Mac stopped showing it). + case codeNotFound + /// Every advertised route points back at this Mac. case loopbackRejected /// The link belongs to a different account. case accountMismatch @@ -98,14 +101,15 @@ public enum ComputersPairResult: Sendable, Equatable { case failed } -/// Result of minting + copying this Mac's pairing link, rendered inline. -public enum ComputersCopyLinkResult: Sendable, Equatable { - /// The link is on the clipboard, ready to paste on the other Mac. - case copied +/// Result of minting this Mac's pairing code, rendered inline under the +/// "Pair This Mac" row. +public enum ComputersPairingCodeMintResult: Sendable, Equatable { + /// The code is being advertised; show it until `expiresAt`. + case minted(code: String, expiresAt: Date) /// This Mac has no Tailscale route another Mac could dial. case needsTailscale /// Pairing requires the Mac to be signed in. case signedOut - /// The pairing listener or ticket mint failed. + /// The pairing listener or registry publish failed. case failed } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 559816ecf866..063d0402ee58 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -162,17 +162,17 @@ public protocol SettingsHostActions: AnyObject { /// routes locally). func pairComputer(deviceID: String) async -> ComputersPairResult - /// Pairs a computer from a pasted `cmux-ios://attach?…` pairing link (the - /// payload another Mac's pairing window shows as a QR code). - func pairComputerWithLink(_ link: String) async -> ComputersPairResult + /// Pairs a computer from the 6-digit code another Mac's "Pair This Mac" + /// row is showing (the registry-rendezvous claim). + func pairComputer(code: String) async -> ComputersPairResult /// Removes the local pairing for a computer. The registry row remains. func unpairComputer(deviceID: String) async - /// Mints this Mac's pairing link (the same payload the pairing window's - /// QR encodes) and puts it on the clipboard, so another Mac can pair by - /// pasting it — no camera involved. - func copyComputerPairingLink() async -> ComputersCopyLinkResult + /// Mints a short-lived 6-digit pairing code, advertises it through the + /// device registry, and returns it for display, so another Mac can pair + /// by typing it — nothing to copy between machines. + func mintComputerPairingCode() async -> ComputersPairingCodeMintResult /// Opens the remote-workspace viewer window for a paired computer. The /// host owns the window, so the package can't open it directly. @@ -260,13 +260,13 @@ public extension SettingsHostActions { func pairComputer(deviceID: String) async -> ComputersPairResult { .failed } /// Default failure for hosts without a computers directory. - func pairComputerWithLink(_ link: String) async -> ComputersPairResult { .failed } + func pairComputer(code: String) async -> ComputersPairResult { .failed } /// Default no-op unpair for previews/tests. func unpairComputer(deviceID: String) async {} /// Default failure for hosts without a pairing listener (previews/tests). - func copyComputerPairingLink() async -> ComputersCopyLinkResult { .failed } + func mintComputerPairingCode() async -> ComputersPairingCodeMintResult { .failed } /// Default no-op viewer open for previews/tests. func openComputerViewer(deviceID: String) {} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 80da7779645d..cb8f09a44c1d 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -3,24 +3,24 @@ import SwiftUI /// **Computers** section — the account's registered computers (from the team /// device registry) with live presence, plus Mac-to-Mac pairing: pair a -/// listed computer in one click, paste a pairing link from another Mac, or -/// show this Mac's own pairing code. +/// listed computer in one click, or pair by typing the 6-digit code another +/// Mac is showing. @MainActor public struct ComputersSection: View { @State private var model: ComputersListModel /// Device ids with a pair/unpair action in flight, so their buttons show /// a busy state without re-rendering unrelated rows. @State private var pendingDeviceIDs: Set = [] - /// The pasted pairing-link draft. - @State private var pairingLink: String = "" - /// Result of the most recent pair action, shown inline under the link row. + /// The typed pairing-code draft. + @State private var codeInput: String = "" + /// Result of the most recent pair action, shown inline under the code row. @State private var pairResult: ComputersPairResult? - /// Guards overlapping link-pair submissions. - @State private var isPairingLink = false - /// Result of the most recent copy-link action, shown under its row. - @State private var copyLinkResult: ComputersCopyLinkResult? - /// Guards overlapping copy-link mints. - @State private var isCopyingLink = false + /// Guards overlapping code-pair submissions. + @State private var isPairingCode = false + /// Result of the most recent mint, shown under the Pair This Mac row. + @State private var mintResult: ComputersPairingCodeMintResult? + /// Guards overlapping mints. + @State private var isMintingCode = false private let hostActions: SettingsHostActions @@ -50,16 +50,24 @@ public struct ComputersSection: View { )) SettingsCardDivider() } - pairingLinkRow + pairingCodeRow pairResultCaption SettingsCardDivider() pairThisMacRow - copyLinkResultCaption + mintResultCaption } } .task { startSettingsObservation([model]) hostActions.refreshComputers() + // Periodic registry re-fetch replaces the old manual Refresh + // button; a bounded cadence delay cancelled with the view's task, + // not condition-polling. + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(30)) + guard !Task.isCancelled else { break } + hostActions.refreshComputers() + } } } @@ -87,58 +95,38 @@ public struct ComputersSection: View { defaultValue: "Couldn't reach the device registry. Showing locally known computers." )) } - refreshRow - } - - @ViewBuilder - private var refreshRow: some View { - SettingsCardRow( - configurationReview: .action, - searchAnchorID: "setting:computers:refresh", - String(localized: "settings.computers.refresh", defaultValue: "Refresh List"), - subtitle: String( - localized: "settings.computers.refresh.subtitle", - defaultValue: "Fetch the account's registered computers again." - ) - ) { - Button(String(localized: "settings.computers.refresh.button", defaultValue: "Refresh")) { - hostActions.refreshComputers() - } - .buttonStyle(.bordered) - .controlSize(.small) - .accessibilityIdentifier("SettingsComputersRefreshButton") - } } @ViewBuilder - private var pairingLinkRow: some View { + private var pairingCodeRow: some View { SettingsCardRow( configurationReview: .action, - searchAnchorID: "setting:computers:pairingLink", - String(localized: "settings.computers.pairingLink", defaultValue: "Add by Pairing Link"), + searchAnchorID: "setting:computers:pairingCode", + String(localized: "settings.computers.pairingCode", defaultValue: "Pair with Code"), subtitle: String( - localized: "settings.computers.pairingLink.subtitle", - defaultValue: "Paste the pairing link from the other Mac's pairing window (the QR code's contents)." + localized: "settings.computers.pairingCode.subtitle", + defaultValue: "Type the 6-digit code shown by “Pair This Mac” on the other Mac." ), - controlWidth: 260 + controlWidth: 200 ) { HStack(spacing: 8) { TextField( - String(localized: "settings.computers.pairingLink.placeholder", defaultValue: "cmux-ios://attach?…"), - text: $pairingLink + String(localized: "settings.computers.pairingCode.placeholder", defaultValue: "6-digit code"), + text: $codeInput ) .textFieldStyle(.roundedBorder) - .onChange(of: pairingLink) { pairResult = nil } - .onSubmit { pairFromLink() } - .accessibilityIdentifier("SettingsComputersPairingLinkField") + .font(.body.monospacedDigit()) + .onChange(of: codeInput) { pairResult = nil } + .onSubmit { pairFromCode() } + .accessibilityIdentifier("SettingsComputersPairingCodeField") - Button(String(localized: "settings.computers.pairingLink.add", defaultValue: "Add")) { - pairFromLink() + Button(String(localized: "settings.computers.pairingCode.pair", defaultValue: "Pair")) { + pairFromCode() } .buttonStyle(.bordered) .controlSize(.small) - .disabled(isPairingLink || pairingLink.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .accessibilityIdentifier("SettingsComputersPairingLinkAddButton") + .disabled(isPairingCode || codeInput.filter(\.isNumber).count != 6) + .accessibilityIdentifier("SettingsComputersPairingCodePairButton") } } } @@ -163,31 +151,53 @@ public struct ComputersSection: View { String(localized: "settings.computers.showPairingCode", defaultValue: "Pair This Mac"), subtitle: String( localized: "settings.computers.showPairingCode.subtitle", - defaultValue: "Copy a pairing link and paste it into “Add by Pairing Link” on another Mac." + defaultValue: "Show a 6-digit code, then type it into “Pair with Code” on another Mac." ) ) { - Button(String(localized: "settings.computers.copyLink.button", defaultValue: "Copy Pairing Link")) { - copyPairingLink() + Button(String(localized: "settings.computers.showPairingCode.button", defaultValue: "Show Pairing Code")) { + mintPairingCode() } .buttonStyle(.bordered) .controlSize(.small) - .disabled(isCopyingLink) - .accessibilityIdentifier("SettingsComputersCopyPairingLinkButton") + .disabled(isMintingCode) + .accessibilityIdentifier("SettingsComputersShowPairingCodeButton") } } @ViewBuilder - private var copyLinkResultCaption: some View { - if let copyLinkResult, let text = Self.copyLinkText(copyLinkResult) { - Label( - text, - systemImage: copyLinkResult == .copied ? "checkmark.circle.fill" : "exclamationmark.triangle.fill" - ) - .foregroundStyle(copyLinkResult == .copied ? AnyShapeStyle(.secondary) : AnyShapeStyle(.orange)) - .cmuxFont(.caption) + private var mintResultCaption: some View { + switch mintResult { + case .minted(let code, let expiresAt): + VStack(alignment: .leading, spacing: 4) { + Text(Self.displayCode(code)) + .font(.system(size: 26, weight: .semibold, design: .monospaced)) + .textSelection(.enabled) + .accessibilityIdentifier("SettingsComputersPairingCodeValue") + HStack(spacing: 4) { + Text(String( + localized: "settings.computers.showPairingCode.expiresIn", + defaultValue: "Expires in" + )) + Text(timerInterval: Date.now...expiresAt, countsDown: true) + .monospacedDigit() + } + .foregroundStyle(.secondary) + .cmuxFont(.caption) + } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.bottom, 8) + case .needsTailscale, .signedOut, .failed: + if let text = Self.mintFailureText(mintResult) { + Label(text, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .cmuxFont(.caption) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.bottom, 8) + } + case nil: + EmptyView() } } @@ -209,50 +219,53 @@ public struct ComputersSection: View { } } - private func copyPairingLink() { - guard !isCopyingLink else { return } - isCopyingLink = true - copyLinkResult = nil + private func mintPairingCode() { + guard !isMintingCode else { return } + isMintingCode = true + mintResult = nil Task { - copyLinkResult = await hostActions.copyComputerPairingLink() - isCopyingLink = false + mintResult = await hostActions.mintComputerPairingCode() + isMintingCode = false } } - private func pairFromLink() { - let link = pairingLink.trimmingCharacters(in: .whitespacesAndNewlines) - guard !isPairingLink, !link.isEmpty else { return } - isPairingLink = true + private func pairFromCode() { + let code = codeInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isPairingCode, code.filter(\.isNumber).count == 6 else { return } + isPairingCode = true pairResult = nil Task { - let result = await hostActions.pairComputerWithLink(link) + let result = await hostActions.pairComputer(code: code) pairResult = result - if result == .paired { pairingLink = "" } - isPairingLink = false + if result == .paired { codeInput = "" } + isPairingCode = false } } - private static func copyLinkText(_ result: ComputersCopyLinkResult) -> String? { + /// `042117` → `042 117`: grouped for reading aloud across the room. + static func displayCode(_ code: String) -> String { + guard code.count == 6 else { return code } + return "\(code.prefix(3)) \(code.suffix(3))" + } + + private static func mintFailureText(_ result: ComputersPairingCodeMintResult?) -> String? { switch result { - case .copied: - return String( - localized: "settings.computers.copyLink.copied", - defaultValue: "Link copied. Paste it into “Add by Pairing Link” on the other Mac." - ) + case .minted, nil: + return nil case .needsTailscale: return String( - localized: "settings.computers.copyLink.needsTailscale", + localized: "settings.computers.showPairingCode.needsTailscale", defaultValue: "No reachable address. Install and sign in to Tailscale on both Macs, then try again." ) case .signedOut: return String( - localized: "settings.computers.copyLink.signedOut", - defaultValue: "Sign in to cmux first, then copy the pairing link." + localized: "settings.computers.showPairingCode.signedOut", + defaultValue: "Sign in to cmux first, then show a pairing code." ) case .failed: return String( - localized: "settings.computers.copyLink.failed", - defaultValue: "Couldn't create a pairing link. Try again." + localized: "settings.computers.showPairingCode.failed", + defaultValue: "Couldn't create a pairing code. Try again." ) } } @@ -262,9 +275,14 @@ public struct ComputersSection: View { case .paired: return String(localized: "settings.computers.pairResult.paired", defaultValue: "Paired.") case .invalidLink: - return String(localized: "settings.computers.pairResult.invalidLink", defaultValue: "That doesn't look like a cmux pairing link.") + return String(localized: "settings.computers.pairResult.invalidLink", defaultValue: "That doesn't look like a cmux pairing code.") + case .codeNotFound: + return String( + localized: "settings.computers.pairResult.codeNotFound", + defaultValue: "No computer is showing that code. Check the digits and that the code hasn't expired." + ) case .loopbackRejected: - return String(localized: "settings.computers.pairResult.loopback", defaultValue: "That link points back at this Mac. Pairing over the network needs Tailscale on both Macs.") + return String(localized: "settings.computers.pairResult.loopback", defaultValue: "That code belongs to this Mac. Enter it on the other Mac instead.") case .accountMismatch: return String(localized: "settings.computers.pairResult.accountMismatch", defaultValue: "That computer is signed in to a different account.") case .noRoutes: diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 4af479ce0d2c..4bf8b6f27548 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -235738,652 +235738,601 @@ } } }, - "settings.computers.refresh": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Refresh List" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "リストを更新" - } - } - } - }, - "settings.computers.refresh.subtitle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Fetch the account's registered computers again." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "アカウントに登録されたコンピュータを再取得します。" - } - } - } - }, - "settings.computers.refresh.button": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Refresh" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "更新" - } - } - } - }, - "settings.computers.pairingLink": { + "settings.computers.showPairingCode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add by Pairing Link" + "value": "Pair This Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングリンクで追加" + "value": "この Mac をペアリング" } } } }, - "settings.computers.pairingLink.subtitle": { + "settings.computers.showPairingCode.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paste the pairing link from the other Mac's pairing window (the QR code's contents)." + "value": "Show a 6-digit code, then type it into “Pair with Code” on another Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "相手の Mac のペアリングウィンドウに表示されるペアリングリンク(QR コードの内容)を貼り付けてください。" + "value": "6 桁のコードを表示し、別の Mac の「コードでペアリング」に入力してください。" } } } }, - "settings.computers.pairingLink.placeholder": { + "settings.computers.showPairingCode.button": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "cmux-ios://attach?…" + "value": "Show Pairing Code" } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux-ios://attach?…" + "value": "ペアリングコードを表示" } } } }, - "settings.computers.pairingLink.add": { + "settings.computers.pairResult.paired": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add" + "value": "Paired." } }, "ja": { "stringUnit": { "state": "translated", - "value": "追加" + "value": "ペアリングしました。" } } } }, - "settings.computers.showPairingCode": { + "settings.computers.pairResult.invalidLink": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair This Mac" + "value": "That doesn't look like a cmux pairing code." } }, "ja": { "stringUnit": { "state": "translated", - "value": "この Mac をペアリング" + "value": "cmux のペアリングコードではないようです。" } } } }, - "settings.computers.showPairingCode.subtitle": { + "settings.computers.pairResult.loopback": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy a pairing link and paste it into “Add by Pairing Link” on another Mac." + "value": "That code belongs to this Mac. Enter it on the other Mac instead." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングリンクをコピーして、別の Mac の「ペアリングリンクで追加」に貼り付けてください。" + "value": "そのコードはこの Mac のものです。もう一方の Mac で入力してください。" } } } }, - "settings.computers.showPairingCode.button": { + "settings.computers.pairResult.accountMismatch": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Show Pairing Code…" + "value": "That computer is signed in to a different account." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングコードを表示…" + "value": "そのコンピュータは別のアカウントでサインインしています。" } } } }, - "settings.computers.pairResult.paired": { + "settings.computers.pairResult.noRoutes": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Paired." + "value": "That computer hasn't advertised a reachable address yet. Make sure Tailscale is running on it." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングしました。" + "value": "そのコンピュータはまだ接続可能なアドレスを公開していません。Tailscale が実行中か確認してください。" } } } }, - "settings.computers.pairResult.invalidLink": { + "settings.computers.pairResult.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "That doesn't look like a cmux pairing link." + "value": "Pairing failed. Try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux のペアリングリンクではないようです。" + "value": "ペアリングに失敗しました。もう一度お試しください。" } } } }, - "settings.computers.pairResult.loopback": { + "settings.computers.row.thisMac": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "That link points back at this Mac. Pairing over the network needs Tailscale on both Macs." + "value": "%@ (This Mac)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "そのリンクはこの Mac 自身を指しています。ネットワーク越しのペアリングには両方の Mac で Tailscale が必要です。" + "value": "%@(この Mac)" } } } }, - "settings.computers.pairResult.accountMismatch": { + "settings.computers.row.lastSeen": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "That computer is signed in to a different account." + "value": "Last seen %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "そのコンピュータは別のアカウントでサインインしています。" + "value": "最終確認 %@" } } } }, - "settings.computers.pairResult.noRoutes": { + "settings.computers.presence.online": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "That computer hasn't advertised a reachable address yet. Make sure Tailscale is running on it." + "value": "Online" } }, "ja": { "stringUnit": { "state": "translated", - "value": "そのコンピュータはまだ接続可能なアドレスを公開していません。Tailscale が実行中か確認してください。" + "value": "オンライン" } } } }, - "settings.computers.pairResult.failed": { + "settings.computers.presence.offline": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pairing failed. Try again." + "value": "Offline" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングに失敗しました。もう一度お試しください。" + "value": "オフライン" } } } }, - "settings.computers.row.thisMac": { + "settings.computers.row.unpair": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ (This Mac)" + "value": "Unpair" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@(この Mac)" + "value": "ペアリング解除" } } } }, - "settings.computers.row.lastSeen": { + "settings.computers.row.pair": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Last seen %@" + "value": "Pair" } }, "ja": { "stringUnit": { "state": "translated", - "value": "最終確認 %@" + "value": "ペアリング" } } } }, - "settings.computers.presence.online": { + "settings.search.alias.section.computers": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Online" + "value": "computers devices macs remote view control pair unpair presence online offline tailscale hive" } }, "ja": { "stringUnit": { "state": "translated", - "value": "オンライン" + "value": "コンピュータ デバイス マック リモート 表示 操作 ペアリング 解除 プレゼンス オンライン オフライン tailscale hive" } } } }, - "settings.computers.presence.offline": { + "hive.viewer.error.noRoute": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Offline" + "value": "This computer hasn't advertised a reachable address. Make sure Tailscale is running on both Macs." } }, "ja": { "stringUnit": { "state": "translated", - "value": "オフライン" + "value": "このコンピュータは接続可能なアドレスを公開していません。両方の Mac で Tailscale が実行中か確認してください。" } } } }, - "settings.computers.row.unpair": { + "hive.viewer.connecting": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unpair" + "value": "Connecting…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリング解除" + "value": "接続中…" } } } }, - "settings.computers.row.pair": { + "hive.viewer.failed.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pair" + "value": "Couldn't Connect" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリング" + "value": "接続できませんでした" } } } }, - "settings.search.alias.section.computers": { + "hive.viewer.retry": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "computers devices macs remote view control pair unpair presence online offline tailscale hive" + "value": "Retry" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コンピュータ デバイス マック リモート 表示 操作 ペアリング 解除 プレゼンス オンライン オフライン tailscale hive" + "value": "再試行" } } } }, - "settings.computers.copyLink.button": { + "hive.viewer.noTerminal.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy Pairing Link" + "value": "No Terminal" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングリンクをコピー" + "value": "ターミナルがありません" } } } }, - "settings.computers.copyLink.copied": { + "hive.viewer.noTerminal.description": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Link copied. Paste it into “Add by Pairing Link” on the other Mac." + "value": "Select a terminal in the sidebar to view it live." } }, "ja": { "stringUnit": { "state": "translated", - "value": "リンクをコピーしました。もう一方の Mac の「ペアリングリンクで追加」に貼り付けてください。" + "value": "サイドバーでターミナルを選択するとライブ表示できます。" } } } }, - "settings.computers.copyLink.needsTailscale": { + "hive.viewer.workspaces.empty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No reachable address. Install and sign in to Tailscale on both Macs, then try again." + "value": "No workspaces yet." } }, "ja": { "stringUnit": { "state": "translated", - "value": "接続可能なアドレスがありません。両方の Mac に Tailscale をインストールしてサインインしてから、もう一度お試しください。" + "value": "ワークスペースはまだありません。" } } } }, - "settings.computers.copyLink.signedOut": { + "hive.viewer.terminal.attaching": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sign in to cmux first, then copy the pairing link." + "value": "Connecting to terminal…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "先に cmux にサインインしてから、ペアリングリンクをコピーしてください。" + "value": "ターミナルに接続中…" } } } }, - "settings.computers.copyLink.failed": { + "hive.viewer.terminal.reattaching": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't create a pairing link. Try again." + "value": "Reconnecting…" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ペアリングリンクを作成できませんでした。もう一度お試しください。" + "value": "再接続中…" } } } }, - "hive.viewer.error.noRoute": { + "settings.computers.row.open": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This computer hasn't advertised a reachable address. Make sure Tailscale is running on both Macs." + "value": "Open" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このコンピュータは接続可能なアドレスを公開していません。両方の Mac で Tailscale が実行中か確認してください。" + "value": "開く" } } } }, - "hive.viewer.connecting": { + "settings.computers.pairingCode": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connecting…" + "value": "Pair with Code" } }, "ja": { "stringUnit": { "state": "translated", - "value": "接続中…" + "value": "コードでペアリング" } } } }, - "hive.viewer.failed.title": { + "settings.computers.pairingCode.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't Connect" + "value": "Type the 6-digit code shown by “Pair This Mac” on the other Mac." } }, "ja": { "stringUnit": { "state": "translated", - "value": "接続できませんでした" + "value": "もう一方の Mac の「この Mac をペアリング」に表示される 6 桁のコードを入力してください。" } } } }, - "hive.viewer.retry": { + "settings.computers.pairingCode.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Retry" + "value": "6-digit code" } }, "ja": { "stringUnit": { "state": "translated", - "value": "再試行" + "value": "6 桁のコード" } } } }, - "hive.viewer.noTerminal.title": { + "settings.computers.pairingCode.pair": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No Terminal" + "value": "Pair" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルがありません" + "value": "ペアリング" } } } }, - "hive.viewer.noTerminal.description": { + "settings.computers.showPairingCode.expiresIn": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Select a terminal in the sidebar to view it live." + "value": "Expires in" } }, "ja": { "stringUnit": { "state": "translated", - "value": "サイドバーでターミナルを選択するとライブ表示できます。" + "value": "残り" } } } }, - "hive.viewer.workspaces.empty": { + "settings.computers.showPairingCode.needsTailscale": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No workspaces yet." + "value": "No reachable address. Install and sign in to Tailscale on both Macs, then try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ワークスペースはまだありません。" + "value": "接続可能なアドレスがありません。両方の Mac に Tailscale をインストールしてサインインしてから、もう一度お試しください。" } } } }, - "hive.viewer.terminal.attaching": { + "settings.computers.showPairingCode.signedOut": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Connecting to terminal…" + "value": "Sign in to cmux first, then show a pairing code." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルに接続中…" + "value": "先に cmux にサインインしてから、ペアリングコードを表示してください。" } } } }, - "hive.viewer.terminal.reattaching": { + "settings.computers.showPairingCode.failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reconnecting…" + "value": "Couldn't create a pairing code. Try again." } }, "ja": { "stringUnit": { "state": "translated", - "value": "再接続中…" + "value": "ペアリングコードを作成できませんでした。もう一度お試しください。" } } } }, - "settings.computers.row.open": { + "settings.computers.pairResult.codeNotFound": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open" + "value": "No computer is showing that code. Check the digits and that the code hasn't expired." } }, "ja": { "stringUnit": { "state": "translated", - "value": "開く" + "value": "そのコードを表示しているコンピュータが見つかりません。数字と有効期限を確認してください。" } } } } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 35e8637cf7f8..78b8177ef4c2 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -35,8 +35,17 @@ final class DeviceRegistryClient { var teamID: String? var tag: String var routes: [CmxAttachRoute] + /// The active Mac-to-Mac pairing code advertised with this + /// registration, so minting or expiring a code re-POSTs even when the + /// routes are unchanged. + var pairingCode: String? } + /// The code currently advertised in this instance's registry labels, if + /// any. Expiry is enforced at read time; an expired value is simply no + /// longer included in the next registration. + private var activePairingCode: CmxPairingCode? + private init() {} /// Inject the auth dependency and begin observing host-route changes. Call @@ -67,10 +76,38 @@ final class DeviceRegistryClient { // Treat "never registered" as an empty-routes baseline in the same scope // so an initial empty set (pairing off at launch) is a no-op, but a later // clear, or any team/tag change, still fires. - let baseline = previous ?? Registration(teamID: current.teamID, tag: current.tag, routes: []) + let baseline = previous + ?? Registration(teamID: current.teamID, tag: current.tag, routes: [], pairingCode: nil) return baseline != current } + /// Advertises a fresh pairing code in this instance's registry labels and + /// returns it for display, or `nil` when the registry rejected the POST + /// (the caller shows a generic failure). + /// + /// The code rides the same registration document as the routes, so a Mac + /// that can see this Mac's row can claim the code; expiry is enforced on + /// the claiming side and the label is dropped from the next registration + /// after `ttl` elapses. + /// + /// - Parameters: + /// - routes: The host's current advertised routes (the caller has just + /// ensured the listener is up); registered together with the code. + /// - ttl: Code lifetime; default 10 minutes. + /// - Returns: The minted code (with expiry) on success. + func publishPairingCode( + routes: [CmxAttachRoute], + ttl: TimeInterval = 600 + ) async -> CmxPairingCode? { + var generator = SystemRandomNumberGenerator() + let minted = CmxPairingCode.minted(ttl: ttl, now: Date(), using: &generator) + activePairingCode = minted + // Force the POST: the code changed even if the routes did not. + lastRegistration = nil + await registerIfRoutesChanged(routes: routes) + return lastRegistration != nil ? minted : nil + } + private func startObserving() { observeTask?.cancel() // Registration is currently driven only by host-route changes. The dedup @@ -106,7 +143,19 @@ final class DeviceRegistryClient { // routes is detected and the POST targets the intended team. let teamID = auth.resolvedTeamID let tag = MobileHostIdentity.instanceTag() - let registration = Registration(teamID: teamID, tag: tag, routes: routes) + // Read expiry once so the dedup key and the POSTed labels agree; an + // expired code drops out of both, clearing the server-side label on + // the next registration. + let currentCode: CmxPairingCode? = { + guard let activePairingCode, activePairingCode.expiresAt > Date() else { return nil } + return activePairingCode + }() + let registration = Registration( + teamID: teamID, + tag: tag, + routes: routes, + pairingCode: currentCode?.code + ) guard Self.shouldReRegister(previous: lastRegistration, current: registration) else { return } guard var comps = URLComponents(url: AuthEnvironment.vmAPIBaseURL, resolvingAgainstBaseURL: false) else { @@ -124,6 +173,9 @@ final class DeviceRegistryClient { if let displayName = MobileHostIdentity.baseDisplayName(), !displayName.isEmpty { bodyDict["displayName"] = displayName } + if let currentCode { + bodyDict["instanceLabels"] = currentCode.instanceLabels + } var req = URLRequest(url: url) req.httpMethod = "POST" diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index 45afc6ab68e3..0aaf7f6e8c76 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -1,4 +1,3 @@ -import AppKit import CmuxHive import CmuxSettingsUI import Foundation @@ -52,9 +51,9 @@ extension HostSettingsActions { return Self.pairResult(await directory.pair(deviceID: deviceID)) } - func pairComputerWithLink(_ link: String) async -> ComputersPairResult { + func pairComputer(code: String) async -> ComputersPairResult { guard let directory = computersDirectory else { return .failed } - return Self.pairResult(await directory.pair(link: link)) + return Self.pairResult(await directory.pair(code: code)) } func unpairComputer(deviceID: String) async { @@ -66,13 +65,13 @@ extension HostSettingsActions { HiveViewerWindowController.shared.show(deviceID: deviceID) } - /// Mints this Mac's attach link (the same payload the pairing window's QR - /// encodes) and copies it to the clipboard for pasting on another Mac. + /// Mints a short-lived 6-digit pairing code and advertises it through the + /// device registry (alongside this Mac's live routes), so another Mac can + /// pair by typing the code — nothing to copy between machines. /// - /// Prefers the Tailscale-only v2 grammar (works across machines). Dev - /// builds fall back to an all-routes ticket when no Tailscale route - /// exists, so two tagged builds on one machine can pair over loopback. - func copyComputerPairingLink() async -> ComputersCopyLinkResult { + /// The listener is brought up first so the registration carries dialable + /// routes for the claiming Mac to persist. + func mintComputerPairingCode() async -> ComputersPairingCodeMintResult { guard let coordinator = AppDelegate.shared?.auth?.coordinator else { return .failed } await coordinator.awaitBootstrapped() guard coordinator.isAuthenticated else { return .signedOut } @@ -80,45 +79,11 @@ extension HostSettingsActions { let host = MobileHostService.shared let status = await host.ensureListeningAndReady() guard status.isRunning else { return .failed } - do { - return copyLinkToPasteboard(try await host.createAttachTicket( - workspaceID: "", - terminalID: nil, - ttl: 600, - target: .physicalDevice - )) - } catch MobileAttachTicketStoreError.noRoutes, - MobileAttachTicketStoreError.routeUnavailable, - MobileAttachTicketStoreError.invalidAttachURL { - #if DEBUG - // Same-machine dogfood: no Tailscale route, but the dev loopback - // route lets a second tagged build on this Mac pair. - do { - return copyLinkToPasteboard(try await host.createAttachTicket( - workspaceID: "", - terminalID: nil, - ttl: 600, - target: .ticketOnly - )) - } catch { - return .needsTailscale - } - #else - return .needsTailscale - #endif - } catch { - return .failed - } - } - - private func copyLinkToPasteboard(_ payload: [String: Any]) -> ComputersCopyLinkResult { - guard let attachURL = payload["attach_url"] as? String, !attachURL.isEmpty else { - return .failed - } - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(attachURL, forType: .string) - return .copied + guard !status.routes.isEmpty else { return .needsTailscale } + guard let minted = await DeviceRegistryClient.shared.publishPairingCode( + routes: status.routes + ) else { return .failed } + return .minted(code: minted.code, expiresAt: minted.expiresAt) } // MARK: - Mapping (pure) @@ -153,6 +118,7 @@ extension HostSettingsActions { switch outcome { case .paired: return .paired case .invalidLink: return .invalidLink + case .codeNotFound: return .codeNotFound case .loopbackRejected: return .loopbackRejected case .accountMismatch: return .accountMismatch case .noRoutes: return .noRoutes diff --git a/cmuxTests/DeviceRegistryClientTests.swift b/cmuxTests/DeviceRegistryClientTests.swift index f50dd5bb13c7..25427f0a64b2 100644 --- a/cmuxTests/DeviceRegistryClientTests.swift +++ b/cmuxTests/DeviceRegistryClientTests.swift @@ -22,8 +22,13 @@ import CMUXMobileCore ) } - private func reg(team: String?, tag: String = "default", routes: [CmxAttachRoute]) -> DeviceRegistryClient.Registration { - DeviceRegistryClient.Registration(teamID: team, tag: tag, routes: routes) + private func reg( + team: String?, + tag: String = "default", + routes: [CmxAttachRoute], + pairingCode: String? = nil + ) -> DeviceRegistryClient.Registration { + DeviceRegistryClient.Registration(teamID: team, tag: tag, routes: routes, pairingCode: pairingCode) } @Test func initialEmptyRoutesDoNotRegister() { @@ -75,4 +80,24 @@ import CMUXMobileCore let current = reg(team: "team-a", routes: []) #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == false) } + + @Test func mintedPairingCodeReRegistersWithUnchangedRoutes() throws { + // Showing a pairing code must re-POST so the registry advertises it, + // even though the route set did not move. + let routes = [try route(host: "100.0.0.1", port: 51000)] + let previous = reg(team: "team-a", routes: routes) + let current = reg(team: "team-a", routes: routes, pairingCode: "042117") + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) + } + + @Test func expiredPairingCodeReRegistersOnceToClearTheLabel() throws { + // After expiry the code drops out of the registration, so the next + // status tick clears the server-side label exactly once. + let routes = [try route(host: "100.0.0.1", port: 51000)] + let previous = reg(team: "team-a", routes: routes, pairingCode: "042117") + let current = reg(team: "team-a", routes: routes) + #expect(DeviceRegistryClient.shouldReRegister(previous: previous, current: current) == true) + // …and unchanged code-less ticks after that stay deduped. + #expect(DeviceRegistryClient.shouldReRegister(previous: current, current: current) == false) + } } From a2e1bc798171290eb71ffad8d6b450caba3b8a67 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 12:56:23 -0700 Subject: [PATCH 16/97] web: port React-19 script-tag patch to @stackframe/stack 2.8.108, patch next-themes The repo's SsrScript patch was keyed to @stackframe/stack@2.8.89 while bun.lock resolves ^2.8.89 to 2.8.108, so bun silently stopped applying it and the dev console warned 'Encountered a script tag while rendering React component' from StackHandler. Regenerate the patch against 2.8.108 and drop the stale 2.8.89 entry. Apply the same useServerInsertedHTML treatment to next-themes@0.4.6, whose ThemeProvider renders its pre-hydration theme-sync script inside the component tree (client behavior unchanged: the provider already applies the theme in an effect; the SSR stream still carries the script, so no theme flash). Co-Authored-By: Claude Fable 5 --- web/bun.lock | 4 ++++ web/package.json | 3 ++- ....89.patch => @stackframe%2Fstack@2.8.108.patch} | 14 ++++---------- web/patches/next-themes@0.4.6.patch | 14 ++++++++++++++ 4 files changed, 24 insertions(+), 11 deletions(-) rename web/patches/{@stackframe%2Fstack@2.8.89.patch => @stackframe%2Fstack@2.8.108.patch} (85%) create mode 100644 web/patches/next-themes@0.4.6.patch diff --git a/web/bun.lock b/web/bun.lock index b3506404b8d3..4c0a74e4f476 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -73,6 +73,10 @@ "sharp", "unrs-resolver", ], + "patchedDependencies": { + "@stackframe/stack@2.8.108": "patches/@stackframe%2Fstack@2.8.108.patch", + "next-themes@0.4.6": "patches/next-themes@0.4.6.patch", + }, "overrides": { "fast-xml-builder": "^1.2.0", "flatted": "^3.4.2", diff --git a/web/package.json b/web/package.json index 30919c0eb187..93950ed4822d 100644 --- a/web/package.json +++ b/web/package.json @@ -109,6 +109,7 @@ "unrs-resolver" ], "patchedDependencies": { - "@stackframe/stack@2.8.89": "patches/@stackframe%2Fstack@2.8.89.patch" + "@stackframe/stack@2.8.108": "patches/@stackframe%2Fstack@2.8.108.patch", + "next-themes@0.4.6": "patches/next-themes@0.4.6.patch" } } diff --git a/web/patches/@stackframe%2Fstack@2.8.89.patch b/web/patches/@stackframe%2Fstack@2.8.108.patch similarity index 85% rename from web/patches/@stackframe%2Fstack@2.8.89.patch rename to web/patches/@stackframe%2Fstack@2.8.108.patch index 0dded6c65b77..e8d3f1a973b8 100644 --- a/web/patches/@stackframe%2Fstack@2.8.89.patch +++ b/web/patches/@stackframe%2Fstack@2.8.108.patch @@ -1,8 +1,8 @@ diff --git a/dist/components/elements/ssr-layout-effect.js b/dist/components/elements/ssr-layout-effect.js -index 99773f6ee51f305b7497b78a55eaca0a4c09d899..57cba1f2eb143fdcd225e5ee6392633e210de390 100644 +index 99773f6ee51f305b7497b78a55eaca0a4c09d899..eb72a6c324ca831cb144f916f5da5fd38e1eb291 100644 --- a/dist/components/elements/ssr-layout-effect.js +++ b/dist/components/elements/ssr-layout-effect.js -@@ -3,20 +3,22 @@ +@@ -3,18 +3,21 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); const require_chunk = require('../../chunk-BE-pF4vm.js'); let react = require("react"); @@ -29,14 +29,11 @@ index 99773f6ee51f305b7497b78a55eaca0a4c09d899..57cba1f2eb143fdcd225e5ee6392633e } //#endregion - exports.SsrScript = SsrScript; --//# sourceMappingURL=ssr-layout-effect.js.map -\ No newline at end of file diff --git a/dist/esm/components/elements/ssr-layout-effect.js b/dist/esm/components/elements/ssr-layout-effect.js -index 1233d79c915fc5b7214fbf3a56570cd9f9f04950..79c8f22b1e5c19d50c52f502f8aa715b4497f7de 100644 +index 1233d79c915fc5b7214fbf3a56570cd9f9f04950..285fc2586e605f3645aaebcc8cbc4062adf7760f 100644 --- a/dist/esm/components/elements/ssr-layout-effect.js +++ b/dist/esm/components/elements/ssr-layout-effect.js -@@ -1,20 +1,26 @@ +@@ -1,18 +1,25 @@ "use client"; import { useLayoutEffect } from "react"; @@ -67,6 +64,3 @@ index 1233d79c915fc5b7214fbf3a56570cd9f9f04950..79c8f22b1e5c19d50c52f502f8aa715b } //#endregion - export { SsrScript }; --//# sourceMappingURL=ssr-layout-effect.js.map -\ No newline at end of file diff --git a/web/patches/next-themes@0.4.6.patch b/web/patches/next-themes@0.4.6.patch new file mode 100644 index 000000000000..3486b8938b14 --- /dev/null +++ b/web/patches/next-themes@0.4.6.patch @@ -0,0 +1,14 @@ +diff --git a/dist/index.js b/dist/index.js +index 7de36edd1ab173cb0410492a9c3ff211314147e5..8fa64ea299e6bbae5ccce21bb7b1e2a6f7dc37fa 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -1 +1 @@ +-"use client";var N=Object.create;var R=Object.defineProperty;var V=Object.getOwnPropertyDescriptor;var _=Object.getOwnPropertyNames;var H=Object.getPrototypeOf,W=Object.prototype.hasOwnProperty;var $=(e,s)=>{for(var n in s)R(e,n,{get:s[n],enumerable:!0})},b=(e,s,n,l)=>{if(s&&typeof s=="object"||typeof s=="function")for(let o of _(s))!W.call(e,o)&&o!==n&&R(e,o,{get:()=>s[o],enumerable:!(l=V(s,o))||l.enumerable});return e};var j=(e,s,n)=>(n=e!=null?N(H(e)):{},b(s||!e||!e.__esModule?R(n,"default",{value:e,enumerable:!0}):n,e)),z=e=>b(R({},"__esModule",{value:!0}),e);var ee={};$(ee,{ThemeProvider:()=>F,useTheme:()=>B});module.exports=z(ee);var t=j(require("react"));var I=(e,s,n,l,o,d,u,h)=>{let m=document.documentElement,w=["light","dark"];function p(r){(Array.isArray(e)?e:[e]).forEach(y=>{let k=y==="class",S=k&&d?o.map(f=>d[f]||f):o;k?(m.classList.remove(...S),m.classList.add(d&&d[r]?d[r]:r)):m.setAttribute(y,r)}),C(r)}function C(r){h&&w.includes(r)&&(m.style.colorScheme=r)}function a(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}if(l)p(l);else try{let r=localStorage.getItem(s)||n,y=u&&r==="system"?a():r;p(y)}catch(r){}};var Q=["light","dark"],D="(prefers-color-scheme: dark)",J=typeof window=="undefined",L=t.createContext(void 0),q={setTheme:e=>{},themes:[]},B=()=>{var e;return(e=t.useContext(L))!=null?e:q},F=e=>t.useContext(L)?t.createElement(t.Fragment,null,e.children):t.createElement(X,{...e}),G=["light","dark"],X=({forcedTheme:e,disableTransitionOnChange:s=!1,enableSystem:n=!0,enableColorScheme:l=!0,storageKey:o="theme",themes:d=G,defaultTheme:u=n?"system":"light",attribute:h="data-theme",value:m,children:w,nonce:p,scriptProps:C})=>{let[a,r]=t.useState(()=>Z(o,u)),[T,y]=t.useState(()=>a==="system"?x():a),k=m?Object.values(m):d,S=t.useCallback(i=>{let c=i;if(!c)return;i==="system"&&n&&(c=x());let v=m?m[c]:c,E=s?K(p):null,P=document.documentElement,M=g=>{g==="class"?(P.classList.remove(...k),v&&P.classList.add(v)):g.startsWith("data-")&&(v?P.setAttribute(g,v):P.removeAttribute(g))};if(Array.isArray(h)?h.forEach(M):M(h),l){let g=Q.includes(u)?u:null,U=Q.includes(c)?c:g;P.style.colorScheme=U}E==null||E()},[p]),f=t.useCallback(i=>{let c=typeof i=="function"?i(a):i;r(c);try{localStorage.setItem(o,c)}catch(v){}},[a]),A=t.useCallback(i=>{let c=x(i);y(c),a==="system"&&n&&!e&&S("system")},[a,e]);t.useEffect(()=>{let i=window.matchMedia(D);return i.addListener(A),A(i),()=>i.removeListener(A)},[A]),t.useEffect(()=>{let i=c=>{c.key===o&&(c.newValue?r(c.newValue):f(u))};return window.addEventListener("storage",i),()=>window.removeEventListener("storage",i)},[f]),t.useEffect(()=>{S(e!=null?e:a)},[e,a]);let O=t.useMemo(()=>({theme:a,setTheme:f,forcedTheme:e,resolvedTheme:a==="system"?T:a,themes:n?[...d,"system"]:d,systemTheme:n?T:void 0}),[a,f,e,T,n,d]);return t.createElement(L.Provider,{value:O},t.createElement(Y,{forcedTheme:e,storageKey:o,attribute:h,enableSystem:n,enableColorScheme:l,defaultTheme:u,value:m,themes:d,nonce:p,scriptProps:C}),w)},Y=t.memo(({forcedTheme:e,storageKey:s,attribute:n,enableSystem:l,enableColorScheme:o,defaultTheme:d,value:u,themes:h,nonce:m,scriptProps:w})=>{let p=JSON.stringify([n,s,d,e,h,u,l,o]).slice(1,-1);return t.createElement("script",{...w,suppressHydrationWarning:!0,nonce:typeof window=="undefined"?m:"",dangerouslySetInnerHTML:{__html:`(${I.toString()})(${p})`}})}),Z=(e,s)=>{if(J)return;let n;try{n=localStorage.getItem(e)||void 0}catch(l){}return n||s},K=e=>{let s=document.createElement("style");return e&&s.setAttribute("nonce",e),s.appendChild(document.createTextNode("*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(s),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(s)},1)}},x=e=>(e||(e=window.matchMedia(D)),e.matches?"dark":"light");0&&(module.exports={ThemeProvider,useTheme}); ++"use client";var N=Object.create;var R=Object.defineProperty;var V=Object.getOwnPropertyDescriptor;var _=Object.getOwnPropertyNames;var H=Object.getPrototypeOf,W=Object.prototype.hasOwnProperty;var $=(e,s)=>{for(var n in s)R(e,n,{get:s[n],enumerable:!0})},b=(e,s,n,l)=>{if(s&&typeof s=="object"||typeof s=="function")for(let o of _(s))!W.call(e,o)&&o!==n&&R(e,o,{get:()=>s[o],enumerable:!(l=V(s,o))||l.enumerable});return e};var j=(e,s,n)=>(n=e!=null?N(H(e)):{},b(s||!e||!e.__esModule?R(n,"default",{value:e,enumerable:!0}):n,e)),z=e=>b(R({},"__esModule",{value:!0}),e);var ee={};$(ee,{ThemeProvider:()=>F,useTheme:()=>B});module.exports=z(ee);var t=j(require("react"));var $nav=require("next/navigation");var I=(e,s,n,l,o,d,u,h)=>{let m=document.documentElement,w=["light","dark"];function p(r){(Array.isArray(e)?e:[e]).forEach(y=>{let k=y==="class",S=k&&d?o.map(f=>d[f]||f):o;k?(m.classList.remove(...S),m.classList.add(d&&d[r]?d[r]:r)):m.setAttribute(y,r)}),C(r)}function C(r){h&&w.includes(r)&&(m.style.colorScheme=r)}function a(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}if(l)p(l);else try{let r=localStorage.getItem(s)||n,y=u&&r==="system"?a():r;p(y)}catch(r){}};var Q=["light","dark"],D="(prefers-color-scheme: dark)",J=typeof window=="undefined",L=t.createContext(void 0),q={setTheme:e=>{},themes:[]},B=()=>{var e;return(e=t.useContext(L))!=null?e:q},F=e=>t.useContext(L)?t.createElement(t.Fragment,null,e.children):t.createElement(X,{...e}),G=["light","dark"],X=({forcedTheme:e,disableTransitionOnChange:s=!1,enableSystem:n=!0,enableColorScheme:l=!0,storageKey:o="theme",themes:d=G,defaultTheme:u=n?"system":"light",attribute:h="data-theme",value:m,children:w,nonce:p,scriptProps:C})=>{let[a,r]=t.useState(()=>Z(o,u)),[T,y]=t.useState(()=>a==="system"?x():a),k=m?Object.values(m):d,S=t.useCallback(i=>{let c=i;if(!c)return;i==="system"&&n&&(c=x());let v=m?m[c]:c,E=s?K(p):null,P=document.documentElement,M=g=>{g==="class"?(P.classList.remove(...k),v&&P.classList.add(v)):g.startsWith("data-")&&(v?P.setAttribute(g,v):P.removeAttribute(g))};if(Array.isArray(h)?h.forEach(M):M(h),l){let g=Q.includes(u)?u:null,U=Q.includes(c)?c:g;P.style.colorScheme=U}E==null||E()},[p]),f=t.useCallback(i=>{let c=typeof i=="function"?i(a):i;r(c);try{localStorage.setItem(o,c)}catch(v){}},[a]),A=t.useCallback(i=>{let c=x(i);y(c),a==="system"&&n&&!e&&S("system")},[a,e]);t.useEffect(()=>{let i=window.matchMedia(D);return i.addListener(A),A(i),()=>i.removeListener(A)},[A]),t.useEffect(()=>{let i=c=>{c.key===o&&(c.newValue?r(c.newValue):f(u))};return window.addEventListener("storage",i),()=>window.removeEventListener("storage",i)},[f]),t.useEffect(()=>{S(e!=null?e:a)},[e,a]);let O=t.useMemo(()=>({theme:a,setTheme:f,forcedTheme:e,resolvedTheme:a==="system"?T:a,themes:n?[...d,"system"]:d,systemTheme:n?T:void 0}),[a,f,e,T,n,d]);return t.createElement(L.Provider,{value:O},t.createElement(Y,{forcedTheme:e,storageKey:o,attribute:h,enableSystem:n,enableColorScheme:l,defaultTheme:u,value:m,themes:d,nonce:p,scriptProps:C}),w)},Y=t.memo(({forcedTheme:e,storageKey:s,attribute:n,enableSystem:l,enableColorScheme:o,defaultTheme:d,value:u,themes:h,nonce:m,scriptProps:w})=>{let p=JSON.stringify([n,s,d,e,h,u,l,o]).slice(1,-1);return $nav.useServerInsertedHTML(()=>t.createElement("script",{...w,nonce:m,dangerouslySetInnerHTML:{__html:`(${I.toString()})(${p})`},key:"next-themes-script"})),null}),Z=(e,s)=>{if(J)return;let n;try{n=localStorage.getItem(e)||void 0}catch(l){}return n||s},K=e=>{let s=document.createElement("style");return e&&s.setAttribute("nonce",e),s.appendChild(document.createTextNode("*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(s),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(s)},1)}},x=e=>(e||(e=window.matchMedia(D)),e.matches?"dark":"light");0&&(module.exports={ThemeProvider,useTheme}); +diff --git a/dist/index.mjs b/dist/index.mjs +index 9675b38b88b935960dbf4d2203cfaaaef13cbef4..360981e6fa3885fa176c76f8a89cc256d842da2c 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -1 +1 @@ +-"use client";import*as t from"react";var M=(e,i,s,u,m,a,l,h)=>{let d=document.documentElement,w=["light","dark"];function p(n){(Array.isArray(e)?e:[e]).forEach(y=>{let k=y==="class",S=k&&a?m.map(f=>a[f]||f):m;k?(d.classList.remove(...S),d.classList.add(a&&a[n]?a[n]:n)):d.setAttribute(y,n)}),R(n)}function R(n){h&&w.includes(n)&&(d.style.colorScheme=n)}function c(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}if(u)p(u);else try{let n=localStorage.getItem(i)||s,y=l&&n==="system"?c():n;p(y)}catch(n){}};var b=["light","dark"],I="(prefers-color-scheme: dark)",O=typeof window=="undefined",x=t.createContext(void 0),U={setTheme:e=>{},themes:[]},z=()=>{var e;return(e=t.useContext(x))!=null?e:U},J=e=>t.useContext(x)?t.createElement(t.Fragment,null,e.children):t.createElement(V,{...e}),N=["light","dark"],V=({forcedTheme:e,disableTransitionOnChange:i=!1,enableSystem:s=!0,enableColorScheme:u=!0,storageKey:m="theme",themes:a=N,defaultTheme:l=s?"system":"light",attribute:h="data-theme",value:d,children:w,nonce:p,scriptProps:R})=>{let[c,n]=t.useState(()=>H(m,l)),[T,y]=t.useState(()=>c==="system"?E():c),k=d?Object.values(d):a,S=t.useCallback(o=>{let r=o;if(!r)return;o==="system"&&s&&(r=E());let v=d?d[r]:r,C=i?W(p):null,P=document.documentElement,L=g=>{g==="class"?(P.classList.remove(...k),v&&P.classList.add(v)):g.startsWith("data-")&&(v?P.setAttribute(g,v):P.removeAttribute(g))};if(Array.isArray(h)?h.forEach(L):L(h),u){let g=b.includes(l)?l:null,D=b.includes(r)?r:g;P.style.colorScheme=D}C==null||C()},[p]),f=t.useCallback(o=>{let r=typeof o=="function"?o(c):o;n(r);try{localStorage.setItem(m,r)}catch(v){}},[c]),A=t.useCallback(o=>{let r=E(o);y(r),c==="system"&&s&&!e&&S("system")},[c,e]);t.useEffect(()=>{let o=window.matchMedia(I);return o.addListener(A),A(o),()=>o.removeListener(A)},[A]),t.useEffect(()=>{let o=r=>{r.key===m&&(r.newValue?n(r.newValue):f(l))};return window.addEventListener("storage",o),()=>window.removeEventListener("storage",o)},[f]),t.useEffect(()=>{S(e!=null?e:c)},[e,c]);let Q=t.useMemo(()=>({theme:c,setTheme:f,forcedTheme:e,resolvedTheme:c==="system"?T:c,themes:s?[...a,"system"]:a,systemTheme:s?T:void 0}),[c,f,e,T,s,a]);return t.createElement(x.Provider,{value:Q},t.createElement(_,{forcedTheme:e,storageKey:m,attribute:h,enableSystem:s,enableColorScheme:u,defaultTheme:l,value:d,themes:a,nonce:p,scriptProps:R}),w)},_=t.memo(({forcedTheme:e,storageKey:i,attribute:s,enableSystem:u,enableColorScheme:m,defaultTheme:a,value:l,themes:h,nonce:d,scriptProps:w})=>{let p=JSON.stringify([s,i,a,e,h,l,u,m]).slice(1,-1);return t.createElement("script",{...w,suppressHydrationWarning:!0,nonce:typeof window=="undefined"?d:"",dangerouslySetInnerHTML:{__html:`(${M.toString()})(${p})`}})}),H=(e,i)=>{if(O)return;let s;try{s=localStorage.getItem(e)||void 0}catch(u){}return s||i},W=e=>{let i=document.createElement("style");return e&&i.setAttribute("nonce",e),i.appendChild(document.createTextNode("*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(i),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(i)},1)}},E=e=>(e||(e=window.matchMedia(I)),e.matches?"dark":"light");export{J as ThemeProvider,z as useTheme}; ++"use client";import*as t from"react";import{useServerInsertedHTML as $ssr}from"next/navigation";var M=(e,i,s,u,m,a,l,h)=>{let d=document.documentElement,w=["light","dark"];function p(n){(Array.isArray(e)?e:[e]).forEach(y=>{let k=y==="class",S=k&&a?m.map(f=>a[f]||f):m;k?(d.classList.remove(...S),d.classList.add(a&&a[n]?a[n]:n)):d.setAttribute(y,n)}),R(n)}function R(n){h&&w.includes(n)&&(d.style.colorScheme=n)}function c(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}if(u)p(u);else try{let n=localStorage.getItem(i)||s,y=l&&n==="system"?c():n;p(y)}catch(n){}};var b=["light","dark"],I="(prefers-color-scheme: dark)",O=typeof window=="undefined",x=t.createContext(void 0),U={setTheme:e=>{},themes:[]},z=()=>{var e;return(e=t.useContext(x))!=null?e:U},J=e=>t.useContext(x)?t.createElement(t.Fragment,null,e.children):t.createElement(V,{...e}),N=["light","dark"],V=({forcedTheme:e,disableTransitionOnChange:i=!1,enableSystem:s=!0,enableColorScheme:u=!0,storageKey:m="theme",themes:a=N,defaultTheme:l=s?"system":"light",attribute:h="data-theme",value:d,children:w,nonce:p,scriptProps:R})=>{let[c,n]=t.useState(()=>H(m,l)),[T,y]=t.useState(()=>c==="system"?E():c),k=d?Object.values(d):a,S=t.useCallback(o=>{let r=o;if(!r)return;o==="system"&&s&&(r=E());let v=d?d[r]:r,C=i?W(p):null,P=document.documentElement,L=g=>{g==="class"?(P.classList.remove(...k),v&&P.classList.add(v)):g.startsWith("data-")&&(v?P.setAttribute(g,v):P.removeAttribute(g))};if(Array.isArray(h)?h.forEach(L):L(h),u){let g=b.includes(l)?l:null,D=b.includes(r)?r:g;P.style.colorScheme=D}C==null||C()},[p]),f=t.useCallback(o=>{let r=typeof o=="function"?o(c):o;n(r);try{localStorage.setItem(m,r)}catch(v){}},[c]),A=t.useCallback(o=>{let r=E(o);y(r),c==="system"&&s&&!e&&S("system")},[c,e]);t.useEffect(()=>{let o=window.matchMedia(I);return o.addListener(A),A(o),()=>o.removeListener(A)},[A]),t.useEffect(()=>{let o=r=>{r.key===m&&(r.newValue?n(r.newValue):f(l))};return window.addEventListener("storage",o),()=>window.removeEventListener("storage",o)},[f]),t.useEffect(()=>{S(e!=null?e:c)},[e,c]);let Q=t.useMemo(()=>({theme:c,setTheme:f,forcedTheme:e,resolvedTheme:c==="system"?T:c,themes:s?[...a,"system"]:a,systemTheme:s?T:void 0}),[c,f,e,T,s,a]);return t.createElement(x.Provider,{value:Q},t.createElement(_,{forcedTheme:e,storageKey:m,attribute:h,enableSystem:s,enableColorScheme:u,defaultTheme:l,value:d,themes:a,nonce:p,scriptProps:R}),w)},_=t.memo(({forcedTheme:e,storageKey:i,attribute:s,enableSystem:u,enableColorScheme:m,defaultTheme:a,value:l,themes:h,nonce:d,scriptProps:w})=>{let p=JSON.stringify([s,i,a,e,h,l,u,m]).slice(1,-1);return $ssr(()=>t.createElement("script",{...w,nonce:d,dangerouslySetInnerHTML:{__html:`(${M.toString()})(${p})`},key:"next-themes-script"})),null}),H=(e,i)=>{if(O)return;let s;try{s=localStorage.getItem(e)||void 0}catch(u){}return s||i},W=e=>{let i=document.createElement("style");return e&&i.setAttribute("nonce",e),i.appendChild(document.createTextNode("*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}")),document.head.appendChild(i),()=>{window.getComputedStyle(document.body),setTimeout(()=>{document.head.removeChild(i)},1)}},E=e=>(e||(e=window.matchMedia(I)),e.matches?"dark":"light");export{J as ThemeProvider,z as useTheme}; From 232893b4e3c0822008b88ae91b0752a2701f91d3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 13:03:47 -0700 Subject: [PATCH 17/97] Repair pbxproj merge resolution: restore isa/closing braces on package entries Co-Authored-By: Claude Fable 5 --- cmux.xcodeproj/project.pbxproj | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6975155fc57c..eae5fff1ab5d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -8596,11 +8596,17 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/macOS/CmuxHiveUI; + }; 1A0B0C0D0E0F101112132011 /* XCLocalSwiftPackageReference "CmuxIrohTransport" */ = { + isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxIrohTransport; + }; 1A0B0C0D0E0F101112132001 /* XCLocalSwiftPackageReference "CmuxMobileTransport" */ = { + isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxMobileTransport; + }; C1A072000000000000000001 /* XCLocalSwiftPackageReference "CmuxMobileRPC" */ = { + isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxMobileRPC; }; 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */ = { @@ -9079,13 +9085,19 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = XCSwiftPackageProductDependency; package = C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */; productName = CmuxHiveUI; + }; 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */ = { + isa = XCSwiftPackageProductDependency; package = 1A0B0C0D0E0F101112132011 /* XCLocalSwiftPackageReference "CmuxIrohTransport" */; productName = CmuxIrohTransport; + }; 1A0B0C0D0E0F101112132002 /* CmuxMobileTransport */ = { + isa = XCSwiftPackageProductDependency; package = 1A0B0C0D0E0F101112132001 /* XCLocalSwiftPackageReference "CmuxMobileTransport" */; productName = CmuxMobileTransport; + }; C1A072000000000000000002 /* CmuxMobileRPC */ = { + isa = XCSwiftPackageProductDependency; package = C1A072000000000000000001 /* XCLocalSwiftPackageReference "CmuxMobileRPC" */; productName = CmuxMobileRPC; }; From f8140dbe1feb7c632cb7e7144a70ccec7a77ef95 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 13:13:51 -0700 Subject: [PATCH 18/97] Post-merge fixes: dedupe searchText into its extension file, restore bonsplit pin The branch had moved SettingsNavigationTarget.searchText to SettingsNavigationTarget+SearchText.swift; keep that home and port main's new .networking alias text there instead of resurrecting the old copy. Pin vendor/bonsplit back to 50a03f96 (fork-destination enum API): main's d06bf35c2f (#8140) requires it, but 4fd4c0371e (#8180) accidentally reverted the submodule pointer to f647ae39, which does not compile from a clean checkout. 50a03f96 is on bonsplit origin/main. Co-Authored-By: Claude Fable 5 --- Sources/SettingsNavigation.swift | 42 ------------------- .../SettingsNavigationTarget+SearchText.swift | 2 + vendor/bonsplit | 2 +- 3 files changed, 3 insertions(+), 43 deletions(-) diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index 3ca4e9befe23..e8f8769349ef 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -109,48 +109,6 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { } } - var searchText: String { - switch self { - case .account: - return "\(title) sign in team sync" - case .app: - return "\(title) appearance language workspace notifications menu bar telemetry default terminal" - case .terminal: - return "\(title) scrollbar auto resume restore reopen relaunch quit sessions agents claude codex opencode rovodev hibernation idle suspend commands approvals prefixes toggle" - case .textBox: - return "\(title) textbox text box rich input prompt beta new terminal workspace split tab focus height" - case .sleepyMode: - return "\(title) sleepy mode screensaver caffeinate keep awake lock touch id battery wifi clock mascot theme glow pixel" - case .mobile: - return "\(title) ios iphone ipad mobile pairing local network sync" - case .computers: - return "\(title) computers devices macs remote pair unpair presence online offline tailscale hive" - case .networking: - return "\(title) iroh relay server private network tailscale vpn direct peer custom provider region" - case .workspaceColors: - return "\(title) palette tabs" - case .sidebarAppearance: - return "\(title) sidebar details branches badges material terminal background" - case .customSidebars: - return "\(title) custom sidebars vibe swift json interpreted renderer in-process remote worker isolated" - case .betaFeatures: - return "\(title) beta experimental unstable feed dock right sidebar" - case .automation: - return "\(title) socket integrations hooks ports claude cursor gemini kiro naming auto naming workspace tabs" - case .browser: - return "\(title) search engine links history theme" - case .browserImport: - return "\(title) browser import data bookmarks history cookies" - case .globalHotkey: - return "\(title) system wide shortcut" - case .keyboardShortcuts: - return "\(title) keybindings commands chords" - case .settingsJSON: - return "\(title) config file preferences editor documentation schema jsonc reload" - case .reset: - return "\(title) defaults" - } - } } enum SettingsNavigationRequest { diff --git a/Sources/SettingsNavigationTarget+SearchText.swift b/Sources/SettingsNavigationTarget+SearchText.swift index c683a3bb311a..7b8dc2e16c70 100644 --- a/Sources/SettingsNavigationTarget+SearchText.swift +++ b/Sources/SettingsNavigationTarget+SearchText.swift @@ -20,6 +20,8 @@ extension SettingsNavigationTarget { return "\(title) ios iphone ipad mobile pairing local network sync" case .computers: return "\(title) computers devices macs remote pair unpair presence online offline tailscale hive" + case .networking: + return "\(title) iroh relay server private network tailscale vpn direct peer custom provider region" case .workspaceColors: return "\(title) palette tabs" case .sidebarAppearance: diff --git a/vendor/bonsplit b/vendor/bonsplit index f647ae39b343..50a03f96582d 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f647ae39b34383808648b68dd43090143c6cd7cb +Subproject commit 50a03f96582d2b10a747b8c7aac62017e6edd7df From 32794666c36b748ce1a68a05ec892ee733818837 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 15:34:12 -0700 Subject: [PATCH 19/97] Hive viewer fixes from dogfood: block self-pairing, fix span width rendering - Self-pairing: pair(deviceID:) refuses ownDeviceID (loopbackRejected) and the This Mac row no longer offers Pair (dev builds advertise loopback routes, so it would even 'work'). - Rendering: the wire cell_width is a span's TOTAL width in cells, not a per-glyph width; the viewer was spacing every character cell_width cells apart, scattering glyphs across the window. HiveTerminalGridModel.Span now carries totalCellWidth (wire value or estimated), the Canvas draws uniform single-width runs as one string and mixed-width runs per character advancing by each character's grid width (CMUXMobileCore width tables, now public). - Update CmuxHive tests to main's encode(_:routeDisclosureMode:) signature. Co-Authored-By: Claude Fable 5 --- ...ileTerminalRenderGridFrame+CellWidth.swift | 13 +++++-- .../CmuxHive/HiveComputerDirectory.swift | 3 ++ .../CmuxHive/HiveTerminalGridModel.swift | 19 ++++++---- .../HiveComputerDirectoryTests.swift | 6 ++-- .../HivePairingLinkDecoderTests.swift | 4 +-- .../HiveTerminalGridModelTests.swift | 35 +++++++++++++++++-- .../CmuxHiveUI/HiveTerminalGridView.swift | 16 +++++---- Sources/HostSettingsActions+Computers.swift | 3 +- 8 files changed, 76 insertions(+), 23 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift index 244638499c2a..d0ff681d87ad 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGridFrame+CellWidth.swift @@ -13,7 +13,9 @@ extension String { return clipped } - var renderGridEstimatedCellWidth: Int { + /// The estimated total grid-cell width of the string (wide glyphs count 2, + /// combining marks 0), matching the host's span-width accounting. + public var renderGridEstimatedCellWidth: Int { reduce(0) { width, character in width + character.renderGridEstimatedCellWidth } @@ -25,13 +27,18 @@ extension MobileTerminalRenderGridFrame.RowSpan { text.unicodeScalars.contains { $0.isRenderGridWidthSensitiveScalar } } - var gridCellWidth: Int { + /// The span's total width in grid cells: the wire `cell_width` when the + /// host sent one, else the text's estimated width. This is the column + /// advance to the next span, not a per-glyph width. + public var gridCellWidth: Int { cellWidth ?? max(1, text.renderGridEstimatedCellWidth) } } extension Character { - var renderGridEstimatedCellWidth: Int { + /// The grid-cell width of one character (0 combining, 2 wide, else 1), + /// matching the host's span-width accounting. + public var renderGridEstimatedCellWidth: Int { let scalars = unicodeScalars guard scalars.contains(where: { !$0.isRenderGridZeroWidthScalar }) else { return 0 diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index c4aaeda02284..6bd601f09320 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -150,6 +150,9 @@ public final class HiveComputerDirectory { /// Pair a computer from its registry row: persist its best instance's /// routes into the local paired store. public func pair(deviceID: String) async -> HivePairOutcome { + // Pairing with the computer you are sitting at is always a mistake + // (dev builds advertise loopback routes, so it would even "work"). + guard deviceID != ownDeviceID else { return .loopbackRejected } guard let computer = computers.first(where: { $0.deviceID == deviceID }), computer.isPairableHost else { return .noRoutes diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift index 42f218781b94..108b3317aa07 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift @@ -22,20 +22,27 @@ public struct HiveTerminalGridModel: Equatable, Sendable { public var style: MobileTerminalRenderGridFrame.Style /// The run's text. public var text: String - /// Terminal cells occupied by each character (2 for wide glyphs). - public var cellWidth: Int + /// The run's **total** width in grid cells (the wire `cell_width` + /// semantics): the column advance to the next span. Wider than + /// `text.count` when the run contains wide glyphs or host-side + /// padding; never a per-glyph width. + public var totalCellWidth: Int public init( column: Int, style: MobileTerminalRenderGridFrame.Style, text: String, - cellWidth: Int = 1 + totalCellWidth: Int? = nil ) { self.column = column self.style = style self.text = text - self.cellWidth = cellWidth + self.totalCellWidth = totalCellWidth ?? max(1, text.renderGridEstimatedCellWidth) } + + /// Whether every character is a plain single-cell glyph, so the whole + /// run can be drawn as one string at the span origin. + public var isUniformSingleWidth: Bool { totalCellWidth == text.count } } /// Grid width in columns. @@ -93,7 +100,7 @@ public struct HiveTerminalGridModel: Equatable, Sendable { column: span.column, style: stylesByID[span.styleID] ?? .default, text: span.text, - cellWidth: max(span.cellWidth ?? 1, 1) + totalCellWidth: max(span.gridCellWidth, 1) ) ) } @@ -121,7 +128,7 @@ public struct HiveTerminalGridModel: Equatable, Sendable { column = span.column } text += span.text - column += span.text.count * span.cellWidth + column += span.totalCellWidth } return text } diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift index 92826fd7ef5d..9c986757f4eb 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -176,7 +176,9 @@ private func makeDirectory( let directory = makeDirectory(registry: .ok([ownDevice, routeless, phone]), store: store) await directory.refresh() - #expect(await directory.pair(deviceID: "self-device") == .noRoutes) + // Self-pairing is refused outright, even though dev builds advertise + // routes for this device. + #expect(await directory.pair(deviceID: "self-device") == .loopbackRejected) #expect(await directory.pair(deviceID: "routeless-mac") == .noRoutes) #expect(await directory.pair(deviceID: "phone") == .noRoutes) let persisted = try await store.loadAll(stackUserID: nil, teamID: nil) @@ -323,7 +325,7 @@ private func makeDirectory( ], expiresAt: nil ) - let link = try #require(CmxPairingQRCode().encode(ticket)) + let link = try #require(CmxPairingQRCode().encode(ticket, routeDisclosureMode: .legacyPrivateNetworkCompatibility)) let directory = makeDirectory(registry: .ok([]), store: store) let outcome = await directory.pair(link: link) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift index 1f32d588f572..dba050766fd5 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift @@ -29,7 +29,7 @@ import Testing /// The v2 pairing URL another Mac's pairing window renders as its QR. private func pairingLink(macUserID: String? = "owner-1") throws -> String { let ticket = try tailscaleTicket(macUserID: macUserID) - return try #require(CmxPairingQRCode().encode(ticket)) + return try #require(CmxPairingQRCode().encode(ticket, routeDisclosureMode: .legacyPrivateNetworkCompatibility)) } @Test func decodesSameAccountLink() throws { @@ -101,7 +101,7 @@ import Testing ], expiresAt: nil ) - let payload = try CmxAttachTicketCompactCoder().encode(loopbackTicket) + let payload = try CmxAttachTicketCompactCoder().encode(loopbackTicket, routeDisclosureMode: .legacyPrivateNetworkCompatibility) let base64 = payload.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift index da054322066d..4cc36015e85c 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift @@ -121,13 +121,44 @@ import Testing #expect(grid.plainRow(0) == "hello") } - @Test func wideGlyphSpansAdvanceByCellWidth() throws { + @Test func wideGlyphSpansAdvanceByTotalCellWidth() throws { var grid = HiveTerminalGridModel() + // The wire `cell_width` is the span's TOTAL width in cells: two wide + // glyphs occupy columns 0-3. grid.apply(try fullFrame(rowSpans: [ - .init(row: 0, column: 0, styleID: 0, text: "日本", cellWidth: 2), + .init(row: 0, column: 0, styleID: 0, text: "日本", cellWidth: 4), .init(row: 0, column: 4, styleID: 0, text: "!"), ])) // The wide span occupies columns 0-3; no gap spaces before "!". #expect(grid.plainRow(0) == "日本!") + #expect(grid.rowSpans[0].first?.totalCellWidth == 4) + #expect(grid.rowSpans[0].first?.isUniformSingleWidth == false) + } + + @Test func omittedCellWidthEstimatesFromText() throws { + var grid = HiveTerminalGridModel() + // No wire cell_width: the span width is estimated from the text (wide + // glyphs count 2), so the following span still needs no gap fill. + grid.apply(try fullFrame(rowSpans: [ + .init(row: 0, column: 0, styleID: 0, text: "日本"), + .init(row: 0, column: 4, styleID: 0, text: "ok"), + ])) + #expect(grid.plainRow(0) == "日本ok") + #expect(grid.rowSpans[0].first?.totalCellWidth == 4) + // Plain ASCII spans stay single-width and drawable as one run. + #expect(grid.rowSpans[0].last?.isUniformSingleWidth == true) + } + + @Test func hostPaddedSpanAdvancesByWireWidth() throws { + var grid = HiveTerminalGridModel() + // The host may send cell_width wider than the text (padded run); the + // next column must respect the wire width, with gap fill in between. + grid.apply(try fullFrame(rowSpans: [ + .init(row: 0, column: 0, styleID: 0, text: "ab", cellWidth: 6), + .init(row: 0, column: 8, styleID: 0, text: "cd"), + ])) + // Column after span 1 = 0 + 6; gap to column 8 = 2 spaces. + #expect(grid.plainRow(0) == "ab cd") + #expect(grid.rowSpans[0].first?.totalCellWidth == 6) } } diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift index 0574bf58eb3f..4bf0cd70f0b4 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift @@ -70,13 +70,12 @@ public struct HiveTerminalGridView: View { foreground = swappedForeground } if style.invisible { return } - let cellCount = span.text.count * span.cellWidth let origin = metrics.origin(row: row, column: span.column) if let background { let rect = CGRect( x: origin.x, y: origin.y, - width: CGFloat(cellCount) * metrics.cellWidth, + width: CGFloat(span.totalCellWidth) * metrics.cellWidth, height: metrics.lineHeight ) context.fill(Path(rect), with: .color(background)) @@ -86,19 +85,22 @@ public struct HiveTerminalGridView: View { attributes.foregroundColor = style.faint ? foreground.opacity(0.6) : foreground if style.underline { attributes.underlineStyle = .single } if style.strikethrough { attributes.strikethroughStyle = .single } - if span.cellWidth == 1 { + if span.isUniformSingleWidth { let text = Text(AttributedString(span.text, attributes: attributes)) context.draw(context.resolve(text), at: origin, anchor: .topLeading) } else { - // Wide glyphs: place each character at its own cell offset so the - // grid stays aligned regardless of the font's natural advance. - for (index, character) in span.text.enumerated() { + // Mixed widths (wide glyphs / combining marks): place each + // character at its own cell offset, advancing by that character's + // grid width, so alignment matches the remote terminal. + var cellOffset = 0 + for character in span.text { let text = Text(AttributedString(String(character), attributes: attributes)) let characterOrigin = CGPoint( - x: origin.x + CGFloat(index * span.cellWidth) * metrics.cellWidth, + x: origin.x + CGFloat(cellOffset) * metrics.cellWidth, y: origin.y ) context.draw(context.resolve(text), at: characterOrigin, anchor: .topLeading) + cellOffset += character.renderGridEstimatedCellWidth } } } diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index 0aaf7f6e8c76..bdf959ad1b28 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -108,7 +108,8 @@ extension HostSettingsActions { symbolName: Self.symbolName(forPlatform: computer.platform), isThisMac: computer.isThisComputer, isPaired: computer.isPaired, - canPair: computer.isPairableHost && !computer.isPaired && computer.bestPairingRoutes != nil, + canPair: !computer.isThisComputer && computer.isPairableHost && !computer.isPaired + && computer.bestPairingRoutes != nil, presence: Self.presence(computer.presence), detail: Self.detail(for: computer) ) From c5f54d4a9924f068ebb88f06782fb2bb78e4a41b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 16:32:02 -0700 Subject: [PATCH 20/97] Hive viewer: trust the Tailscale tunnel for Stack auth (Mac viewer only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's authenticated-Iroh transport work (#7908) made routeAllowsStackAuth fail-closed (loopback only), which broke the Mac-to-Mac viewer: it dials registry-advertised Tailscale routes and authorizes with the Stack token, so every request threw insecureManualRoute ('Couldn't Connect'). Add an explicit StackAuthChannelTrust set on the policy and thread it through MobileCoreRPCClient. iOS keeps the fail-closed default; only HiveRemoteMacSession opts into loopbackAndTailscaleTunnel — the tunnel is WireGuard-encrypted and the routes come from the signed-in account's own device registry, and a non-tailnet host smuggled under the tailscale route kind still never receives the token (policy test). Co-Authored-By: Claude Fable 5 --- .../CmuxMobileRPC/MobileCoreRPCClient.swift | 16 +++++-- .../MobileShellRouteAuthPolicy.swift | 46 +++++++++++++++++-- .../MobileShellRouteAuthPolicyTests.swift | 29 ++++++++++++ .../CmuxHive/HiveRemoteMacSession.swift | 6 ++- 4 files changed, 88 insertions(+), 9 deletions(-) diff --git a/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift index 38f9e998a890..24dac295ad40 100644 --- a/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift +++ b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift @@ -1,5 +1,5 @@ public import CMUXMobileCore -internal import CmuxMobileShellModel +public import CmuxMobileShellModel internal import CmuxMobileSupport public import Foundation internal import os @@ -17,6 +17,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { /// The attach ticket this client uses to authorize RPC requests. public var attachTicket: CmxAttachTicket { ticket } private let allowsStackAuthFallback: Bool + private let stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust // `internal` (not `private`) so `@testable import` can observe session // queue state from tests, instead of exposing a debug hook in production. let session: MobileCoreRPCSession @@ -30,11 +31,17 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { /// - ticket: The attach ticket authorizing requests. /// - allowsStackAuthFallback: When `true`, falls back to a Stack Auth token /// on routes that allow it once the attach ticket no longer covers a request. + /// - stackAuthChannelTrust: The channel set trusted to carry the Stack + /// bearer token; defaults to the fail-closed loopback-only iOS policy. + /// The macOS remote-Mac viewer passes + /// ``MobileShellRouteAuthPolicy/StackAuthChannelTrust/loopbackAndTailscaleTunnel`` + /// because its routes come from the account's own device registry. public init( runtime: any MobileSyncRuntime, route: CmxAttachRoute, ticket: CmxAttachTicket, allowsStackAuthFallback: Bool = false, + stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust = .loopbackOnly, connectAttemptRegistry: MobileRPCConnectAttemptRegistry = MobileRPCConnectAttemptRegistry(), stackTokenGate: RPCStackTokenGate? = nil, stackTokenForceRefreshGate: RPCStackTokenGate? = nil, @@ -52,6 +59,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { ) self.transportRequest = transportRequest self.allowsStackAuthFallback = allowsStackAuthFallback + self.stackAuthChannelTrust = stackAuthChannelTrust self.stackTokenGate = stackTokenGate ?? RPCStackTokenGate(timedOutResetNanoseconds: stackTokenGateResetNanoseconds) self.stackTokenForceRefreshGate = stackTokenForceRefreshGate @@ -278,7 +286,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { // expiry), so they never reach this branch. if !ticket.isExpired(at: runtime.now()) { auth["attach_token"] = attachToken - } else if !allowsStackAuthFallback || !MobileShellRouteAuthPolicy.routeAllowsStackAuth(route) { + } else if !allowsStackAuthFallback || !MobileShellRouteAuthPolicy.routeAllowsStackAuth(route, trust: stackAuthChannelTrust) { throw MobileShellConnectionError.attachTicketExpired } } @@ -293,7 +301,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { let shouldSendStackAuth = requestNeedsAuth if shouldSendStackAuth { guard allowsStackAuthFallback, - MobileShellRouteAuthPolicy.routeAllowsStackAuth(route) else { + MobileShellRouteAuthPolicy.routeAllowsStackAuth(route, trust: stackAuthChannelTrust) else { throw MobileShellConnectionError.insecureManualRoute } do { @@ -322,7 +330,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable { if !requestNeedsAuth, isHostStatusRequest(request), allowsStackAuthFallback, - MobileShellRouteAuthPolicy.routeAllowsStackAuth(route), + MobileShellRouteAuthPolicy.routeAllowsStackAuth(route, trust: stackAuthChannelTrust), let stackAccessToken = try await stackAccessTokenForStatus(deadline: deadline) { auth["stack_access_token"] = stackAccessToken } diff --git a/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift index 614a36cc328e..80961a6f1f8a 100644 --- a/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift +++ b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift @@ -77,19 +77,57 @@ public struct MobileShellRouteAuthPolicy { /// the local network before the Mac proves it is the same-account host. /// Iroh routes always return `false`. Their authenticated session context /// authorizes RPC without disclosing the account bearer token to the peer. - /// - Parameter route: The candidate attach route. - /// - Returns: `true` only for a loopback route. - public static func routeAllowsStackAuth(_ route: CmxAttachRoute) -> Bool { + /// - Parameters: + /// - route: The candidate attach route. + /// - trust: The channels the caller trusts with the bearer token; + /// defaults to the fail-closed iOS policy (loopback only). + /// - Returns: `true` when the route is within the trusted channel set. + public static func routeAllowsStackAuth( + _ route: CmxAttachRoute, + trust: StackAuthChannelTrust = .loopbackOnly + ) -> Bool { switch (route.kind, route.endpoint) { case (.debugLoopback, let .hostPort(host, _)): return isLoopbackHost(host) - case (.tailscale, .hostPort), (.iroh, .peer): + case (.tailscale, let .hostPort(host, _)): + return trust == .loopbackAndTailscaleTunnel && isTailscaleHost(host) + case (.iroh, .peer): return false default: return false } } + /// The channel set a client trusts to carry the Stack bearer token. + public enum StackAuthChannelTrust: Sendable, Equatable { + /// Loopback only — the fail-closed default. iOS pairing routes come + /// from scanned/pasted payloads or the LAN, so the phone never sends + /// the bearer token off-device before the host proves itself + /// (main-line policy since the authenticated-Iroh transport). + case loopbackOnly + /// Loopback plus hosts inside the WireGuard-encrypted Tailscale + /// tunnel (`100.64/10` or `*.ts.net`). Opted into by the macOS + /// remote-Mac viewer, whose routes come from the signed-in account's + /// own device registry rather than an untrusted payload. + case loopbackAndTailscaleTunnel + } + + private static func isTailscaleHost(_ host: String) -> Bool { + let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return isTailscaleDNSHost(normalizedHost) || isTailscaleIPv4Host(normalizedHost) + } + + private static func isTailscaleIPv4Host(_ host: String) -> Bool { + guard let octets = ipv4Octets(host) else { + return false + } + return octets[0] == 100 && (64...127).contains(octets[1]) + } + + private static func isTailscaleDNSHost(_ host: String) -> Bool { + host.hasSuffix(".ts.net") + } + /// Whether a decoded pairing/attach ticket must be rejected because its /// routes dial the device itself. /// diff --git a/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift index 834e60902beb..d73815ba9a88 100644 --- a/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift +++ b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift @@ -146,4 +146,33 @@ import Testing [loopback], isPhysicalDevice: false )) } + + @Test func stackAuthChannelTrustGatesTailscaleOnly() throws { + let tailscaleIP = try hostPortRoute(kind: .tailscale, host: "100.71.210.41", port: CmxMobileDefaults.defaultHostPort) + let tailscaleDNS = try hostPortRoute(kind: .tailscale, host: "mini.tail1234.ts.net", port: CmxMobileDefaults.defaultHostPort) + let lanUnderTailscaleKind = try hostPortRoute(kind: .tailscale, host: "192.168.1.20", port: CmxMobileDefaults.defaultHostPort) + let loopback = try hostPortRoute(kind: .debugLoopback, host: "127.0.0.1", port: CmxMobileDefaults.defaultHostPort) + + // Fail-closed default (iOS): tailscale routes never carry the token. + #expect(!MobileShellRouteAuthPolicy.routeAllowsStackAuth(tailscaleIP)) + #expect(!MobileShellRouteAuthPolicy.routeAllowsStackAuth(tailscaleDNS)) + + // The Mac viewer's opt-in trusts the tunnel — but only for hosts that + // are actually inside it, so a LAN host smuggled under the tailscale + // kind still never receives the token. + #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth( + tailscaleIP, trust: .loopbackAndTailscaleTunnel + )) + #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth( + tailscaleDNS, trust: .loopbackAndTailscaleTunnel + )) + #expect(!MobileShellRouteAuthPolicy.routeAllowsStackAuth( + lanUnderTailscaleKind, trust: .loopbackAndTailscaleTunnel + )) + // Loopback stays allowed in both trust sets. + #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth(loopback)) + #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth( + loopback, trust: .loopbackAndTailscaleTunnel + )) + } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index 91a37c89d7a0..dffd01bed031 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -128,11 +128,15 @@ public final class HiveRemoteMacSession { displayName: displayName, route: route ) else { continue } + // Mac-to-Mac viewer: routes come from the signed-in account's own + // device registry, so the WireGuard-encrypted Tailscale tunnel is + // trusted to carry the Stack token (iOS stays loopback-only). let candidate = MobileCoreRPCClient( runtime: runtime, route: route, ticket: ticket, - allowsStackAuthFallback: true + allowsStackAuthFallback: true, + stackAuthChannelTrust: .loopbackAndTailscaleTunnel ) do { let workspaces = try await Self.fetchWorkspaces(client: candidate) From 30220ad3dd1ee1f81aca5370d665fd1924f19f46 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 17:20:58 -0700 Subject: [PATCH 21/97] =?UTF-8?q?Computers:=20configurable=20dual=20view?= =?UTF-8?q?=20=E2=80=94=20per-computer=20windows=20or=20main-sidebar=20sco?= =?UTF-8?q?pe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds computers.presentation ('windows' default | 'sidebar'), a Picker in Settings > Computers, cmux.json schema + localized descriptions, and both presentations of a paired computer: - sidebar: a computer scope picker at the bottom of the main sidebar (the macOS counterpart of the iOS workspace-title Mac picker) switches the window between This Mac and a paired computer; a new SidebarSelection .computer case swaps the content area to an embedded live viewer page. Sessions are cached per device (HiveComputersService.embeddedSession) so scope flips reuse the connection; unpair discards the cached session. Scope is not session-persisted (live connections restore to .tabs). - windows: the existing per-computer auxiliary viewer window; Settings' Open now honors the setting and routes to whichever surface is chosen. Scope-picker rows are value snapshots (snapshot-boundary rule); the picker observes the directory stream in its own leaf view so registry churn never re-renders sidebar rows. en + ja strings included. Co-Authored-By: Claude Fable 5 --- .../CmuxHiveUI/HiveEmbeddedComputerPage.swift | 55 +++++++ .../Keys/ComputersCatalogSection.swift | 22 +++ .../CmuxSettings/Keys/SettingCatalog.swift | 2 + .../Values/ComputersPresentationMode.swift | 19 +++ .../Scene/SettingsWindowScene.swift | 9 +- .../Sections/ComputersSection.swift | 53 ++++++- Resources/Localizable.xcstrings | 136 ++++++++++++++++++ Sources/AppDelegate.swift | 4 + Sources/ContentView.swift | 32 ++++- Sources/Hive/HiveComputersService.swift | 27 ++++ Sources/HostSettingsActions+Computers.swift | 29 +++- Sources/SessionPersistence.swift | 4 + ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 86 +++++++++++ web/data/cmux.schema.json | 15 ++ web/messages/en.json | 3 + web/messages/ja.json | 3 + 16 files changed, 486 insertions(+), 13 deletions(-) create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveEmbeddedComputerPage.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/ComputersCatalogSection.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ComputersPresentationMode.swift diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveEmbeddedComputerPage.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveEmbeddedComputerPage.swift new file mode 100644 index 000000000000..4aedb3c8b703 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveEmbeddedComputerPage.swift @@ -0,0 +1,55 @@ +public import CmuxHive +public import SwiftUI + +/// Hosts a remote computer's live viewer inside the main window's content +/// area (the `computers.presentation = sidebar` mode) — the same +/// session/list/pane stack the auxiliary viewer window shows, minus the +/// window. +/// +/// The session comes from an injected async provider (the app caches one +/// session per device), so switching computer scope back and forth reuses +/// the existing connection instead of re-dialing. +public struct HiveEmbeddedComputerPage: View { + private let deviceID: String + private let sessionProvider: @MainActor (String) async -> HiveRemoteMacSession? + @State private var session: HiveRemoteMacSession? + @State private var failedToStart = false + + /// Creates the embedded page for one computer. + /// - Parameters: + /// - deviceID: The paired computer's registry device id. + /// - sessionProvider: Returns the (cached) live session for a device, + /// or `nil` when the pairing is missing. + public init( + deviceID: String, + sessionProvider: @escaping @MainActor (String) async -> HiveRemoteMacSession? + ) { + self.deviceID = deviceID + self.sessionProvider = sessionProvider + } + + public var body: some View { + Group { + if let session { + HiveViewerRootView(session: session) + } else if failedToStart { + ContentUnavailableView( + String(localized: "hive.embedded.unavailable", defaultValue: "Computer Unavailable"), + systemImage: "desktopcomputer.trianglebadge.exclamationmark", + description: Text(String( + localized: "hive.embedded.unavailable.detail", + defaultValue: "This computer isn't paired anymore. Pair it again in Settings › Computers." + )) + ) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .task(id: deviceID) { + failedToStart = false + session = await sessionProvider(deviceID) + failedToStart = session == nil + } + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/ComputersCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/ComputersCatalogSection.swift new file mode 100644 index 000000000000..8c40fe7229d7 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/ComputersCatalogSection.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Settings for paired remote computers (the `computers.*` keys). The pairing +/// itself lives in the device registry and the local paired store; this +/// section holds how paired computers are presented. +public struct ComputersCatalogSection: SettingCatalogSection { + /// How remote computers are shown: `windows` (default; one auxiliary + /// viewer window per computer) or `sidebar` (merged into the main window + /// behind the sidebar's computer scope picker). + /// + /// JSON-backed so it can be flipped by editing `~/.config/cmux/cmux.json`: + /// + /// ```json + /// { "computers": { "presentation": "sidebar" } } + /// ``` + public let presentation = JSONKey( + id: "computers.presentation", + defaultValue: .windows + ) + + public init() {} +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift index c5cf37f6593a..31889db4f816 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift @@ -42,6 +42,8 @@ public struct SettingCatalog: SettingCatalogSection { public let fileEditor = FileEditorCatalogSection() /// Settings for Mobile pairing and sync. public let mobile = MobileCatalogSection() + /// Settings for paired remote computers (the `computers.*` keys). + public let computers = ComputersCatalogSection() public let betaFeatures = BetaFeaturesCatalogSection() /// Settings for custom (user/agent-authored) sidebars (the `customSidebars.*` keys). public let customSidebars = CustomSidebarsCatalogSection() diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ComputersPresentationMode.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ComputersPresentationMode.swift new file mode 100644 index 000000000000..b699610f59f9 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ComputersPresentationMode.swift @@ -0,0 +1,19 @@ +import Foundation + +/// How paired remote computers are presented in the app. +/// +/// Stored under the catalog entry ``ComputersCatalogSection/presentation`` +/// (`computers.presentation` in `~/.config/cmux/cmux.json`). The raw values +/// are the on-disk strings, so they must not be renamed without a migration. +public enum ComputersPresentationMode: String, CaseIterable, Sendable, SettingCodable { + /// Each remote computer opens in its own auxiliary viewer window + /// (the default): one window per computer, opened from Settings › + /// Computers or the sidebar scope picker. + case windows + + /// Remote computers merge into the main window: the sidebar scope picker + /// (bottom of the workspace sidebar) switches between This Mac, a + /// specific computer, or all computers — mirroring the iOS app's + /// workspace-title Mac picker. + case sidebar +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift index 9f2e2ac065ff..c07935425f46 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift @@ -448,8 +448,13 @@ public struct SettingsWindowRoot: View { MobileSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) .id(anchorID(for: .mobile)) - ComputersSection(hostActions: hostActions) - .id(anchorID(for: .computers)) + ComputersSection( + hostActions: hostActions, + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog + ) + .id(anchorID(for: .computers)) IrohNetworkingSection(hostActions: hostActions) .id(anchorID(for: .networking)) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index cb8f09a44c1d..03fab5997658 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -1,4 +1,5 @@ import CmuxFoundation +import CmuxSettings import SwiftUI /// **Computers** section — the account's registered computers (from the team @@ -8,6 +9,7 @@ import SwiftUI @MainActor public struct ComputersSection: View { @State private var model: ComputersListModel + @State private var presentation: JSONValueModel /// Device ids with a pair/unpair action in flight, so their buttons show /// a busy state without re-rendering unrelated rows. @State private var pendingDeviceIDs: Set = [] @@ -25,10 +27,24 @@ public struct ComputersSection: View { private let hostActions: SettingsHostActions /// Creates the Computers section bound to the host bridge. - /// - Parameter hostActions: Supplies the merged computer snapshots and - /// the pair/unpair/refresh actions. - public init(hostActions: SettingsHostActions) { + /// - Parameters: + /// - hostActions: Supplies the merged computer snapshots and the + /// pair/unpair/refresh actions. + /// - jsonStore: The `cmux.json` store backing the presentation mode. + /// - catalog: The setting catalog carrying `computers.presentation`. + /// - errorLog: Sink for JSON write failures. + public init( + hostActions: SettingsHostActions, + jsonStore: JSONConfigStore, + catalog: SettingCatalog, + errorLog: SettingsErrorLog + ) { _model = State(initialValue: ComputersListModel(hostActions: hostActions)) + _presentation = State(initialValue: JSONValueModel( + store: jsonStore, + key: catalog.computers.presentation, + errorLog: errorLog + )) self.hostActions = hostActions } @@ -55,10 +71,13 @@ public struct ComputersSection: View { SettingsCardDivider() pairThisMacRow mintResultCaption + SettingsCardDivider() + presentationRow } } .task { startSettingsObservation([model]) + presentation.startObserving() hostActions.refreshComputers() // Periodic registry re-fetch replaces the old manual Refresh // button; a bounded cadence delay cancelled with the view's task, @@ -201,6 +220,34 @@ public struct ComputersSection: View { } } + @ViewBuilder + private var presentationRow: some View { + SettingsCardRow( + configurationReview: .json("computers.presentation"), + searchAnchorID: "setting:computers:presentation", + String(localized: "settings.computers.presentation", defaultValue: "Open Computers In"), + subtitle: presentation.current == .windows + ? String( + localized: "settings.computers.presentation.windows.subtitle", + defaultValue: "Each paired computer opens in its own window." + ) + : String( + localized: "settings.computers.presentation.sidebar.subtitle", + defaultValue: "Paired computers merge into the main sidebar behind its computer picker." + ) + ) { + Picker("", selection: Binding(get: { presentation.current }, set: { presentation.set($0) })) { + Text(String(localized: "settings.computers.presentation.windows", defaultValue: "Separate Windows")) + .tag(ComputersPresentationMode.windows) + Text(String(localized: "settings.computers.presentation.sidebar", defaultValue: "Main Sidebar")) + .tag(ComputersPresentationMode.sidebar) + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("SettingsComputersPresentationPicker") + } + } + private func pair(deviceID: String) { guard !pendingDeviceIDs.contains(deviceID) else { return } pendingDeviceIDs.insert(deviceID) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ecac39adaf04..216cb16887d4 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -238419,6 +238419,142 @@ } } } + }, + "settings.computers.presentation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Computers In" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンピュータの表示先" + } + } + } + }, + "settings.computers.presentation.windows.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Each paired computer opens in its own window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリング済みのコンピュータをそれぞれ別のウインドウで開きます。" + } + } + } + }, + "settings.computers.presentation.sidebar.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Paired computers merge into the main sidebar behind its computer picker." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ペアリング済みのコンピュータをメインサイドバーのコンピュータピッカーに統合します。" + } + } + } + }, + "settings.computers.presentation.windows": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Separate Windows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "別ウインドウ" + } + } + } + }, + "settings.computers.presentation.sidebar": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Main Sidebar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メインサイドバー" + } + } + } + }, + "hive.scopePicker.thisMac": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac" + } + } + } + }, + "hive.embedded.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Computer Unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンピュータを利用できません" + } + } + } + }, + "hive.embedded.unavailable.detail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This computer isn't paired anymore. Pair it again in Settings › Computers." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このコンピュータはペアリングが解除されています。設定 › コンピュータで再度ペアリングしてください。" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9ed909b24811..cdc6bfb84cdb 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -3936,6 +3936,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent hasher.combine(0) case .notifications: hasher.combine(1) + case .computer(let deviceID): + hasher.combine(2) + hasher.combine(deviceID) } if let window = context.window ?? windowForMainWindowId(context.windowId) { @@ -12330,6 +12333,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent switch sidebarSelection { case .tabs: return "tabs" case .notifications: return "notifications" + case .computer: return "computer" } }() writeMultiWindowNotificationTestData([ diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b77b017b63b0..a5e9807028a1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -4,6 +4,8 @@ import CmuxCommandPalette import CmuxCore import CmuxFeedback import CmuxFoundation +import CmuxHive +import CmuxHiveUI import CmuxPanes import CmuxSettings import CmuxWorkspaces @@ -1733,6 +1735,14 @@ struct ContentView: View { .opacity(sidebarSelectionState.selection == .notifications ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) + + if case let .computer(deviceID) = sidebarSelectionState.selection { + HiveEmbeddedComputerPage( + deviceID: deviceID, + sessionProvider: { await HiveComputersService.shared.embeddedSession(deviceID: $0) } + ) + .zIndex(3) + } } .modifier(WorkspacePresentationModeContentTopPaddingModifier( isFullScreen: isFullScreen, @@ -10342,12 +10352,16 @@ struct VerticalTabsSidebar: View { } else { extensionSidebarScrollArea(renderContext: renderContext) } - SidebarFooter( - updateViewModel: updateViewModel, - fileExplorerState: fileExplorerState, - modifierKeyMonitor: modifierKeyMonitor, - onSendFeedback: onSendFeedback - ) + VStack(alignment: .leading, spacing: 4) { + HiveSidebarScopePicker(selection: $selection) + .padding(.leading, 8) + SidebarFooter( + updateViewModel: updateViewModel, + fileExplorerState: fileExplorerState, + modifierKeyMonitor: modifierKeyMonitor, + onSendFeedback: onSendFeedback + ) + } .frame(maxWidth: .infinity, alignment: .leading) } .accessibilityIdentifier("Sidebar") @@ -15807,7 +15821,11 @@ private struct ExtensionSidebarBrowserStackDropDelegate: DropDelegate { } } -enum SidebarSelection { +enum SidebarSelection: Equatable { case tabs case notifications + /// A paired remote computer presented in the main window's content area + /// (the `computers.presentation = sidebar` mode). Not persisted across + /// relaunch: sessions are live connections, so restore lands on `.tabs`. + case computer(deviceID: String) } diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 3139cab124ab..d5e0cbacc742 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -76,6 +76,33 @@ final class HiveComputersService { ) } + /// Live sessions backing the main window's embedded computer pages + /// (`computers.presentation = sidebar`), one per device, so scope + /// switches reuse the connection instead of re-dialing. + private var embeddedSessions: [String: HiveRemoteMacSession] = [:] + + /// The cached embedded-viewer session for a device, creating and + /// connecting one on first use. Returns `nil` when the computer has no + /// pairing record or auth is not configured. + func embeddedSession(deviceID: String) async -> HiveRemoteMacSession? { + if let existing = embeddedSessions[deviceID] { return existing } + guard let session = await makeViewerSession(deviceID: deviceID) else { return nil } + // Re-check after the await: a concurrent first call may have won. + if let existing = embeddedSessions[deviceID] { + await session.disconnect() + return existing + } + session.connect() + embeddedSessions[deviceID] = session + return session + } + + /// Tears down the embedded session for a device (unpair, sign-out). + func discardEmbeddedSession(deviceID: String) async { + guard let session = embeddedSessions.removeValue(forKey: deviceID) else { return } + await session.disconnect() + } + /// Dev builds may pair over loopback so two instances on one machine can /// dogfood Mac-to-Mac viewing; release builds never dial themselves. private static var allowsLoopbackPairing: Bool { diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index bdf959ad1b28..2bc5b9c1853b 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -1,4 +1,5 @@ import CmuxHive +import CmuxSettings import CmuxSettingsUI import Foundation @@ -59,10 +60,36 @@ extension HostSettingsActions { func unpairComputer(deviceID: String) async { guard let directory = computersDirectory else { return } await directory.unpair(deviceID: deviceID) + await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) } func openComputerViewer(deviceID: String) { - HiveViewerWindowController.shared.show(deviceID: deviceID) + // Honor the presentation setting: sidebar mode routes the key main + // window's content to the embedded computer page (same page the + // sidebar scope picker drives); windows mode opens/focuses the + // per-computer auxiliary viewer window. + Task { @MainActor in + let presentation: ComputersPresentationMode + if let runtime = AppDelegate.shared?.settingsRuntime { + presentation = await runtime.jsonStore.value(for: runtime.catalog.computers.presentation) + } else { + presentation = .windows + } + switch presentation { + case .sidebar: + guard let appDelegate = AppDelegate.shared, + let context = appDelegate.mainWindowContexts.values.first(where: { $0.window?.isKeyWindow == true }) + ?? appDelegate.mainWindowContexts.values.first(where: { $0.window != nil }) + else { + HiveViewerWindowController.shared.show(deviceID: deviceID) + return + } + context.sidebarSelectionState.selection = .computer(deviceID: deviceID) + context.window?.makeKeyAndOrderFront(nil) + case .windows: + HiveViewerWindowController.shared.show(deviceID: deviceID) + } + } } /// Mints a short-lived 6-digit pairing code and advertises it through the diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f719b55e2242..901d0dab3f35 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -207,6 +207,10 @@ enum SessionSidebarSelection: String, Codable, Sendable, Equatable { self = .tabs case .notifications: self = .notifications + case .computer: + // Remote-computer scope is a live connection, not restorable UI + // state; a restored session lands back on the workspace list. + self = .tabs } } diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index bcd46768806f..84e8d60a2042 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -1,12 +1,98 @@ import AppKit import CmuxAppKitSupportUI import CmuxFoundation +import CmuxHive +import CmuxSettings +import CmuxSettingsUI import CmuxSidebar import CmuxSidebarProviderKit import CmuxUpdater import CmuxWorkspaces import SwiftUI +/// One paired remote computer as a value snapshot for the sidebar scope +/// picker (snapshot-boundary rule: no store references in row content). +struct HiveScopeComputer: Equatable, Identifiable { + let id: String + let name: String +} + +/// Bottom-of-sidebar computer scope picker, shown only in +/// `computers.presentation = sidebar` mode when paired computers exist: +/// switches the main window between This Mac and a remote computer's live +/// workspaces — the macOS counterpart of the iOS workspace-title Mac picker. +struct HiveSidebarScopePicker: View { + @Binding var selection: SidebarSelection + @LiveSetting(\.computers.presentation) private var presentation + @State private var computers: [HiveScopeComputer] = [] + + var body: some View { + if presentation == .sidebar, !computers.isEmpty { + Menu { + Button { + selection = .tabs + } label: { + if activeDeviceID == nil { + Label(thisMacTitle, systemImage: "checkmark") + } else { + Text(thisMacTitle) + } + } + Divider() + ForEach(computers) { computer in + Button { + selection = .computer(deviceID: computer.id) + } label: { + if activeDeviceID == computer.id { + Label(computer.name, systemImage: "checkmark") + } else { + Text(computer.name) + } + } + } + } label: { + Label(currentTitle, systemImage: "desktopcomputer") + .cmuxFont(.caption) + .lineLimit(1) + } + .menuStyle(.borderlessButton) + .fixedSize() + .accessibilityIdentifier("SidebarComputerScopePicker") + .task { await observeComputers() } + } else { + // Keep the observation alive so the picker appears the moment a + // computer is paired while the sidebar is already visible. + Color.clear + .frame(width: 0, height: 0) + .task { await observeComputers() } + } + } + + private var activeDeviceID: String? { + if case let .computer(deviceID) = selection { return deviceID } + return nil + } + + private var thisMacTitle: String { + String(localized: "hive.scopePicker.thisMac", defaultValue: "This Mac") + } + + private var currentTitle: String { + guard let activeDeviceID else { return thisMacTitle } + return computers.first(where: { $0.id == activeDeviceID })?.name ?? thisMacTitle + } + + private func observeComputers() async { + guard let directory = HiveComputersService.shared.directory else { return } + for await merged in directory.updates() { + let snapshot = merged + .filter { $0.isPaired && !$0.isThisComputer } + .map { HiveScopeComputer(id: $0.deviceID, name: $0.displayName) } + if snapshot != computers { computers = snapshot } + } + } +} + /// Footer debug controls and empty-area drop targets for the vertical tabs sidebar, extracted from `ContentView.swift`, which sits at its file-length budget. struct SidebarFooterIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 32d8441a31c8..bbd7d53a5e3c 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1507,6 +1507,21 @@ } } }, + "computers": { + "title": "computers", + "description": "Paired remote computers from Settings > Computers.", + "type": "object", + "additionalProperties": false, + "properties": { + "presentation": { + "type": "string", + "enum": ["windows", "sidebar"], + "default": "windows", + "description": "How paired computers open: `windows` gives each computer its own viewer window; `sidebar` merges them into the main sidebar behind its computer picker.", + "descriptionKey": "schemaDescriptions.computers.presentation" + } + } + }, "diffViewer": { "title": "diffViewer", "description": "Built-in diff viewer settings.", diff --git a/web/messages/en.json b/web/messages/en.json index e85f52834d6d..8002845398e2 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1781,6 +1781,9 @@ "agentPermissionPrompt": "Notify when an agent (e.g. Claude Code) is blocked waiting for your permission to run a tool. On by default, since this is the alert you must act on to unblock the agent.", "agentTurnComplete": "When to notify that an agent finished a turn. whenIdle (default) suppresses the notification while the agent still has a running background task or a pending scheduled wakeup, so you are pinged once work truly drains. always notifies on every turn end; never disables it.", "agentIdleReminder": "Notify when an agent has been idle waiting for your input (about 60s after a turn ends). Suppressed while background work from the last turn is still pending, so a running build or watcher does not trigger a false waiting alert." + }, + "computers": { + "presentation": "How paired computers open: `windows` gives each computer its own viewer window; `sidebar` merges them into the main sidebar behind its computer picker." } }, "exampleMarkdownFontSize": "Default body font size (points) for newly opened markdown viewers.", diff --git a/web/messages/ja.json b/web/messages/ja.json index c8fa09d625d4..b0e8dfcfe076 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1704,6 +1704,9 @@ "agentPermissionPrompt": "エージェント(Claude Code など)がツールの実行にあなたの許可を待って停止しているときに通知します。エージェントの停止を解除するには操作が必要なため、既定でオンです。", "agentTurnComplete": "エージェントがターンを終えたことを通知するタイミングです。whenIdle(既定)は、実行中のバックグラウンドタスクや保留中のスケジュール再開が残っている間は通知を抑制し、作業が完全に片付いてから通知します。always はターン終了ごとに通知し、never は通知しません。", "agentIdleReminder": "エージェントがあなたの入力を待って待機状態になったとき(ターン終了の約60秒後)に通知します。前のターンのバックグラウンド作業がまだ実行中の間は抑制されるため、ビルドやウォッチャーの実行中に誤った待機通知が出ることはありません。" + }, + "computers": { + "presentation": "ペアリング済みコンピュータの開き方: `windows` は各コンピュータを専用のビューアウインドウで開き、`sidebar` はメインサイドバーのコンピュータピッカーに統合します。" } }, "exampleMarkdownFontSize": "新しく開く Markdown ビューアの本文フォントサイズ(ポイント単位)。", From 889f2d2f8a4eb6f26f5f388350ccc69e18c13a94 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 19:40:06 -0700 Subject: [PATCH 22/97] Trust Tailscale's IPv6 ULA range in the Mac viewer's Stack-auth channel set Hosts advertise fd7a:115c:a1e0::/48 fallback routes alongside the 100.64/10 IPv4 address; the viewer's trust check only recognized IPv4 and *.ts.net, so an IPv6 tailnet route was rejected as insecure. Co-Authored-By: Claude Fable 5 --- .../MobileShellRouteAuthPolicy.swift | 15 ++++++++++++++- .../MobileShellRouteAuthPolicyTests.swift | 9 +++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift index 80961a6f1f8a..0a234765f515 100644 --- a/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift +++ b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift @@ -114,7 +114,20 @@ public struct MobileShellRouteAuthPolicy { private static func isTailscaleHost(_ host: String) -> Bool { let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return isTailscaleDNSHost(normalizedHost) || isTailscaleIPv4Host(normalizedHost) + return isTailscaleDNSHost(normalizedHost) + || isTailscaleIPv4Host(normalizedHost) + || isTailscaleIPv6Host(normalizedHost) + } + + /// Tailscale's IPv6 ULA range (`fd7a:115c:a1e0::/48`): every tailnet peer + /// also gets an address here, and hosts advertise it as a fallback route. + private static func isTailscaleIPv6Host(_ host: String) -> Bool { + let bare = host.hasPrefix("[") && host.hasSuffix("]") + ? String(host.dropFirst().dropLast()) + : host + return bare.hasPrefix("fd7a:115c:a1e0:") + || bare == "fd7a:115c:a1e0::" + || bare.hasPrefix("fd7a:115c:a1e0::") } private static func isTailscaleIPv4Host(_ host: String) -> Bool { diff --git a/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift index d73815ba9a88..74a5730a6392 100644 --- a/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift +++ b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift @@ -169,6 +169,15 @@ import Testing #expect(!MobileShellRouteAuthPolicy.routeAllowsStackAuth( lanUnderTailscaleKind, trust: .loopbackAndTailscaleTunnel )) + // Tailscale's IPv6 ULA range is part of the tunnel too — hosts + // advertise it as a fallback route. + let tailscaleIPv6 = try hostPortRoute( + kind: .tailscale, host: "fd7a:115c:a1e0::f536:b524", port: CmxMobileDefaults.defaultHostPort + ) + #expect(!MobileShellRouteAuthPolicy.routeAllowsStackAuth(tailscaleIPv6)) + #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth( + tailscaleIPv6, trust: .loopbackAndTailscaleTunnel + )) // Loopback stays allowed in both trust sets. #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth(loopback)) #expect(MobileShellRouteAuthPolicy.routeAllowsStackAuth( From 82c831b96a9ffa7957c764c30b4130a2c49cef16 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 19:51:53 -0700 Subject: [PATCH 23/97] Hive viewer: dedicated tailnet-verified Tailscale transport factory Main's shared CmxNetworkByteTransportFactory now fails closed for tailscale routes (correct for phone pairings from untrusted payloads), which killed the Mac viewer's TCP-over-WireGuard lane with tailscaleAuthorizationUnavailable. Register HiveTailscaleByteTransportFactory for .tailscale in HiveSyncRuntime.network: it builds the plaintext-TCP transport only after verifying the host is inside the tailnet address space (100.64/10, the fd7a:115c:a1e0::/48 ULA, or *.ts.net); anything else fails exactly like the shared factory. iOS keeps the fail-closed path. Co-Authored-By: Claude Fable 5 --- .../Sources/CmuxHive/HiveSyncRuntime.swift | 13 +++++- .../HiveTailscaleByteTransportFactory.swift | 43 +++++++++++++++++++ ...veTailscaleByteTransportFactoryTests.swift | 33 ++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift index c8972d83365f..d29463e9b818 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift @@ -65,9 +65,18 @@ public struct HiveSyncRuntime: MobileSyncRuntime, Sendable { let supportedKinds: [CmxAttachTransportKind] = allowsLoopbackRoutes ? [.debugLoopback, .tailscale] : [.tailscale] + // Tailscale goes through the viewer's own tailnet-verified factory: + // the shared network factory fails closed for tailscale (correct for + // phone pairings from untrusted payloads), while the viewer's routes + // come from the account's own device registry. let networkFactory = CmxNetworkByteTransportFactory(supportedKinds: supportedKinds) - let registrations = supportedKinds.map { - CmxRouteTransportFactoryRegistration(kind: $0, factory: networkFactory) + let registrations = supportedKinds.map { kind in + CmxRouteTransportFactoryRegistration( + kind: kind, + factory: kind == .tailscale + ? HiveTailscaleByteTransportFactory() + : networkFactory + ) } let transportFactory: any CmxByteTransportFactory do { diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift new file mode 100644 index 000000000000..c085cad2ca40 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift @@ -0,0 +1,43 @@ +public import CMUXMobileCore +internal import CmuxMobileShellModel +internal import CmuxMobileTransport + +/// Mac-viewer-only transport factory for `.tailscale` routes. +/// +/// The shared `CmxNetworkByteTransportFactory` fails closed for tailscale: +/// a phone pairing from a scanned/pasted payload must never send the account +/// bearer over a tunnel Network.framework cannot prove is Tailscale's. The +/// Mac viewer's trust context differs — its routes come from the signed-in +/// account's own device registry — so this factory restores the +/// TCP-over-WireGuard lane, but only after verifying the host is actually +/// inside the tailnet address space (`100.64/10`, Tailscale's IPv6 ULA, or +/// `*.ts.net`); anything else fails exactly like the shared factory. +public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { + /// Creates the factory. + public init() {} + + /// Route-only variant; same verification as the request variant. + public func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + try makeVerifiedTransport(route: route) + } + + /// Builds a TCP transport for a verified tailnet route. + /// - Throws: `CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable` + /// when the route is not a tailnet-classified tailscale host. + public func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport { + try makeVerifiedTransport(route: request.route) + } + + private func makeVerifiedTransport(route: CmxAttachRoute) throws -> any CmxByteTransport { + guard route.kind == .tailscale, + case let .hostPort(host, port) = route.endpoint, + MobileShellRouteAuthPolicy.routeAllowsStackAuth( + route, + trust: .loopbackAndTailscaleTunnel + ) + else { + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + } + return try CmxNetworkByteTransport(host: host, port: port) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift new file mode 100644 index 000000000000..06c816f2e9d8 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift @@ -0,0 +1,33 @@ +import CMUXMobileCore +import CmuxMobileTransport +import Testing +@testable import CmuxHive + +/// The viewer's tailscale transport factory must only dial hosts that are +/// verifiably inside the tailnet address space. +struct HiveTailscaleByteTransportFactoryTests { + private func route(kind: CmxAttachTransportKind, host: String) throws -> CmxAttachRoute { + try CmxAttachRoute(id: "r", kind: kind, endpoint: .hostPort(host: host, port: 52422)) + } + + @Test func buildsTransportsForTailnetHostsOnly() throws { + let factory = HiveTailscaleByteTransportFactory() + #expect(throws: Never.self) { + _ = try factory.makeTransport(for: try route(kind: .tailscale, host: "100.65.181.35")) + } + #expect(throws: Never.self) { + _ = try factory.makeTransport(for: try route(kind: .tailscale, host: "fd7a:115c:a1e0::f536:b524")) + } + #expect(throws: Never.self) { + _ = try factory.makeTransport(for: try route(kind: .tailscale, host: "mini.tail1234.ts.net")) + } + // A LAN or loopback host smuggled under the tailscale kind fails + // exactly like the shared fail-closed factory. + #expect(throws: (any Error).self) { + _ = try factory.makeTransport(for: try route(kind: .tailscale, host: "192.168.86.21")) + } + #expect(throws: (any Error).self) { + _ = try factory.makeTransport(for: try route(kind: .debugLoopback, host: "127.0.0.1")) + } + } +} From 88c30c7fc81aff4648d56e960533f5b596bd4a95 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 11:57:06 -0700 Subject: [PATCH 24/97] =?UTF-8?q?Hive:=20native=20mirror=20workspaces=20?= =?UTF-8?q?=E2=80=94=20remote=20Mac=20terminals=20in=20the=20real=20cmux?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar mode now materializes a paired computer's workspaces as NATIVE mirror workspaces (the remote-tmux mirror recipe pointed at the hive RPC): each remote workspace becomes a real sidebar Workspace whose tabs are manual-I/O ghostty surfaces. HiveRemoteTerminalSession gains an optional frameBytesHandler that emits every applied render-grid frame as VT bytes (full frames as replacement bytes, deltas as patches) via MobileTerminalRenderGridReplay; HiveComputerMirrorController feeds them through TerminalSurface.processRemoteOutput and returns typed input over mobile.terminal.input. The scope picker and Settings Open drive the mirrors; workspace titles carry the device name. v1 scope: topology snapshotted at attach; live reconcile and remote workspace/tab creation are follow-ups. Mirror workspaces reuse the isRemoteTmuxMirror behavior set (restore exclusion, no browser panes, manual-IO tab APIs); tmux command routing no-ops for them. Co-Authored-By: Claude Fable 5 --- .../CmuxHive/HiveRemoteTerminalSession.swift | 18 ++- Resources/Localizable.xcstrings | 17 +++ .../Hive/HiveComputerMirrorController.swift | 133 ++++++++++++++++++ Sources/HostSettingsActions+Computers.swift | 8 +- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 12 +- cmux.xcodeproj/project.pbxproj | 4 + 6 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 Sources/Hive/HiveComputerMirrorController.swift diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift index f878643947da..a2e74a4762a4 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -1,7 +1,7 @@ internal import CMUXMobileCore public import CmuxMobileRPC public import CmuxMobileTerminalKit -import Foundation +public import Foundation public import Observation /// A live view onto one remote terminal: the render-grid stream reduced into @@ -36,6 +36,11 @@ public final class HiveRemoteTerminalSession { public private(set) var phase: Phase = .idle /// The reduced grid the view renders. public private(set) var grid = HiveTerminalGridModel() + /// Optional native-surface sink: every applied frame is also emitted as + /// VT bytes (a full frame as replacement bytes, a delta as patch bytes) + /// so a manual-I/O ghostty surface can render the stream natively. The + /// grid model keeps reducing in parallel for tests and fallbacks. + @ObservationIgnored public var frameBytesHandler: (@MainActor (Data) -> Void)? @ObservationIgnored private let client: MobileCoreRPCClient @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void @@ -150,7 +155,7 @@ public final class HiveRemoteTerminalSession { guard let payload = envelope.payloadJSON, let frame = Self.decodeFrame(payload), frame.surfaceID == terminalID else { continue } - grid.apply(frame) + applyFrame(frame) } // Stream finished: transport died. Re-subscribe + re-replay. if Task.isCancelled { return } @@ -171,10 +176,17 @@ public final class HiveRemoteTerminalSession { let data = try await client.sendRequest(request) let response = try MobileTerminalReplayResponse.decode(data) if let frame = response.renderGrid { - grid.apply(frame) + applyFrame(frame) } } + private func applyFrame(_ frame: MobileTerminalRenderGridFrame) { + grid.apply(frame) + guard let frameBytesHandler else { return } + let replay = MobileTerminalRenderGridReplay(frame) + frameBytesHandler(frame.full ? replay.replacementBytes() : replay.patchBytes()) + } + /// Decode one `terminal.render_grid` event payload: either the wrapped /// `{"render_grid": …}` form or the bare frame. static func decodeFrame(_ payload: Data) -> MobileTerminalRenderGridFrame? { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 216cb16887d4..2ed85c071f4b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -238555,6 +238555,23 @@ } } } + }, + "hive.mirror.workspaceTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ — %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ — %2$@" + } + } + } } }, "version": "1.0" diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift new file mode 100644 index 000000000000..fd16206b7194 --- /dev/null +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -0,0 +1,133 @@ +import CmuxHive +import Foundation + +/// Presents a paired remote Mac's cmux workspaces as NATIVE local mirror +/// workspaces — the remote-tmux mirror recipe pointed at the hive RPC stream. +/// +/// Each remote workspace becomes a real sidebar `Workspace` whose tabs are +/// manual-I/O ghostty surfaces: `HiveRemoteTerminalSession` streams +/// render-grid frames, `MobileTerminalRenderGridReplay` turns them into VT +/// bytes fed through `TerminalSurface.processRemoteOutput`, and typed input +/// returns over `mobile.terminal.input`. The result is the exact cmux UI — +/// sidebar rows, tab bar, pane chrome — backed by the other Mac's terminals. +/// +/// v1 scope: the remote topology is snapshotted at attach (one workspace per +/// remote workspace, one tab per remote terminal); live topology reconcile +/// and remote workspace/tab creation are follow-ups. +@MainActor +final class HiveComputerMirrorController { + static let shared = HiveComputerMirrorController() + private init() {} + + private struct DeviceMirror { + var workspaceIds: [UUID] = [] + var terminals: [HiveRemoteTerminalSession] = [] + } + + private var mirrorsByDeviceID: [String: DeviceMirror] = [:] + + /// Whether `workspaceId` is one of this controller's mirror workspaces. + func ownsWorkspace(_ workspaceId: UUID) -> Bool { + mirrorsByDeviceID.values.contains { $0.workspaceIds.contains(workspaceId) } + } + + /// Attaches (or re-focuses) a paired computer's workspaces as native + /// mirror workspaces in `tabManager`. + /// - Returns: the first mirror workspace id, or `nil` when the session + /// could not be created or the computer reported no workspaces. + @discardableResult + func attach(deviceID: String, into tabManager: TabManager) async -> UUID? { + if let existing = mirrorsByDeviceID[deviceID], + let firstId = existing.workspaceIds.first, + let workspace = tabManager.workspacesById[firstId] { + tabManager.selectWorkspace(workspace) + return firstId + } + guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID), + let client = session.client else { return nil } + // The session connects asynchronously; wait briefly for the first + // workspace list rather than mirroring an empty snapshot. + var attempts = 0 + while session.workspaces.isEmpty, attempts < 40 { + try? await Task.sleep(for: .milliseconds(250)) + attempts += 1 + } + let remoteWorkspaces = session.workspaces + guard !remoteWorkspaces.isEmpty else { return nil } + let computerName = HiveComputersService.shared.directory?.computers + .first(where: { $0.deviceID == deviceID })?.displayName + ?? session.displayName + + var mirror = DeviceMirror() + for remote in remoteWorkspaces { + let title = String( + localized: "hive.mirror.workspaceTitle", + defaultValue: "\(remote.title) — \(computerName)" + ) + let workspace = tabManager.addWorkspace( + title: title, + select: false, + autoWelcomeIfNeeded: false, + autoRefreshMetadata: false + ) + // Reuses the remote-tmux mirror behavior set: manual-I/O display + // tabs, restore exclusion, no local browser panes. Remote-tmux + // command routing no-ops for this workspace (no tmux mirror is + // registered for it). + workspace.isRemoteTmuxMirror = true + let defaultPanelIds = Array(workspace.panels.keys) + + for (index, terminal) in remote.terminals.enumerated() { + let terminalSession = HiveRemoteTerminalSession( + client: client, + workspaceID: remote.id, + terminalID: terminal.id, + retryDelay: { @Sendable attempt in + await HiveReconnectBackoff().delay(attempt: attempt) + } + ) + guard let panel = workspace.addRemoteTmuxDisplayPane( + remotePaneId: index, + title: terminal.title, + focus: false, + onInput: { data in + let text = String(decoding: data, as: UTF8.self) + guard !text.isEmpty else { return } + Task { @MainActor in terminalSession.send(text: text) } + } + ) else { continue } + terminalSession.frameBytesHandler = { [weak panel] bytes in + guard !bytes.isEmpty else { return } + panel?.surface.processRemoteOutput(bytes) + } + terminalSession.attach() + mirror.terminals.append(terminalSession) + } + + // Drop the workspace's default local shell tab; the mirror tabs + // are the workspace now (same move as the tmux mirror). + for panelId in defaultPanelIds where workspace.panels[panelId] != nil { + _ = workspace.removeRemoteTmuxDisplayPane(panelId) + } + mirror.workspaceIds.append(workspace.id) + } + guard !mirror.workspaceIds.isEmpty else { return nil } + mirrorsByDeviceID[deviceID] = mirror + if let firstId = mirror.workspaceIds.first, + let workspace = tabManager.workspacesById[firstId] { + tabManager.selectWorkspace(workspace) + } + return mirror.workspaceIds.first + } + + /// Detaches a computer's mirrors: stops the terminal streams and closes + /// the mirror workspaces. + func detach(deviceID: String, from tabManager: TabManager) { + guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } + for terminal in mirror.terminals { terminal.detach() } + for workspaceId in mirror.workspaceIds { + guard let workspace = tabManager.workspacesById[workspaceId] else { continue } + tabManager.closeWorkspace(workspace) + } + } +} diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index 2bc5b9c1853b..0713a5a6e61a 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -84,7 +84,13 @@ extension HostSettingsActions { HiveViewerWindowController.shared.show(deviceID: deviceID) return } - context.sidebarSelectionState.selection = .computer(deviceID: deviceID) + // Native mirrors: the computer's workspaces join the main + // sidebar as real workspaces. + context.sidebarSelectionState.selection = .tabs + _ = await HiveComputerMirrorController.shared.attach( + deviceID: deviceID, + into: context.tabManager + ) context.window?.makeKeyAndOrderFront(nil) case .windows: HiveViewerWindowController.shared.show(deviceID: deviceID) diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 84e8d60a2042..fde7b9d12d4a 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -23,6 +23,7 @@ struct HiveScopeComputer: Equatable, Identifiable { /// workspaces — the macOS counterpart of the iOS workspace-title Mac picker. struct HiveSidebarScopePicker: View { @Binding var selection: SidebarSelection + @EnvironmentObject var tabManager: TabManager @LiveSetting(\.computers.presentation) private var presentation @State private var computers: [HiveScopeComputer] = [] @@ -41,7 +42,16 @@ struct HiveSidebarScopePicker: View { Divider() ForEach(computers) { computer in Button { - selection = .computer(deviceID: computer.id) + // Native mirrors: the computer's workspaces become + // real sidebar workspaces; selection stays on .tabs. + selection = .tabs + let manager = tabManager + Task { @MainActor in + _ = await HiveComputerMirrorController.shared.attach( + deviceID: computer.id, + into: manager + ) + } } label: { if activeDeviceID == computer.id { Label(computer.name, systemImage: "checkmark") diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index eae5fff1ab5d..0a74f32899d4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -894,6 +894,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE34020000000000000005 /* HelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE34020000000000000006 /* HelpMenuUITests.swift */; }; F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */; }; 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */; }; + C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */; }; C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D2 /* HiveComputersService.swift */; }; C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D5 /* HiveViewerWindowController.swift */; }; CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; @@ -2818,6 +2819,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE34020000000000000006 /* HelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpMenuUITests.swift; sourceTree = ""; }; F5320001A1B2C3D4E5F60718 /* HermesAgentIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HermesAgentIndex.swift; sourceTree = ""; }; 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenRightSidebarContentMountingTests.swift; sourceTree = ""; }; + C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputerMirrorController.swift; sourceTree = ""; }; C41FE00000000000000000D2 /* HiveComputersService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputersService.swift; sourceTree = ""; }; C41FE00000000000000000D5 /* HiveViewerWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveViewerWindowController.swift; sourceTree = ""; }; CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; @@ -4101,6 +4103,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = isa = PBXGroup; children = ( C41FE00000000000000000D2 /* HiveComputersService.swift */, + C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */, C41FE00000000000000000D5 /* HiveViewerWindowController.swift */, ); name = Hive; @@ -6813,6 +6816,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A6AC72010000000000000001 /* GPUSpinnerNSView.swift in Sources */, A6AC72020000000000000001 /* GPUSpinnerStyle.swift in Sources */, F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */, + C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */, C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */, C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, From b66f649aa9c0f852d6ad14673c3c34484d6ef17b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 14:07:30 -0700 Subject: [PATCH 25/97] Hive mirrors: live topology reconcile, remote-grid sizing, computer scope filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reconcile: HiveRemoteMacSession gains workspaceUpdates(); the mirror controller consumes it, adding/removing mirror workspaces and terminal tabs as the host's list changes (fixes missing workspaces from the old attach-time snapshot). - Rendering: the remote grid is authoritative for mirror surfaces — each frame applies applyMobileViewportLimit(columns:rows:) when the grid dims change, and a replay re-request fires whenever the local surface resizes (fixes blank panes and row-misplaced TUI output). - Scope: iOS-style sidebar computer scope (This Mac / All Computers / one device) — a shared HiveSidebarScopeModel filters the workspace list before render items, and the bottom picker sets scope + attaches mirrors. Co-Authored-By: Claude Fable 5 --- .../CmuxHive/HiveRemoteMacSession.swift | 32 ++- .../CmuxHive/HiveRemoteTerminalSession.swift | 9 + Resources/Localizable.xcstrings | 17 ++ Sources/ContentView.swift | 14 +- .../Hive/HiveComputerMirrorController.swift | 239 ++++++++++++------ ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 105 +++++--- 6 files changed, 305 insertions(+), 111 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index dffd01bed031..34debde65547 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -48,6 +48,8 @@ public final class HiveRemoteMacSession { @ObservationIgnored public private(set) var client: MobileCoreRPCClient? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var connectTask: Task? + @ObservationIgnored private var workspaceListeners: + [UUID: AsyncStream<[HiveRemoteWorkspace]>.Continuation] = [:] /// Creates a session onto one paired Mac. /// @@ -102,13 +104,39 @@ public final class HiveRemoteMacSession { public func refreshWorkspaces() async { guard let client else { return } do { - workspaces = try await Self.fetchWorkspaces(client: client) + setWorkspaces(try await Self.fetchWorkspaces(client: client)) } catch { // Keep the stale list; the event loop's stream death drives the // visible reconnect state. } } + /// A stream of workspace lists: the current value immediately, then every + /// change (the session refreshes on `workspace.updated` push events). + /// Native mirror reconciliation consumes this to add/remove local mirror + /// workspaces as the host's topology changes. + public func workspaceUpdates() -> AsyncStream<[HiveRemoteWorkspace]> { + let id = UUID() + let (stream, continuation) = AsyncStream<[HiveRemoteWorkspace]>.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + workspaceListeners[id] = continuation + continuation.yield(workspaces) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.workspaceListeners.removeValue(forKey: id) + } + } + return stream + } + + private func setWorkspaces(_ newWorkspaces: [HiveRemoteWorkspace]) { + workspaces = newWorkspaces + for (_, continuation) in workspaceListeners { + continuation.yield(newWorkspaces) + } + } + // MARK: - Connect private func runConnect() async { @@ -142,7 +170,7 @@ public final class HiveRemoteMacSession { let workspaces = try await Self.fetchWorkspaces(client: candidate) if let previous = client { await previous.disconnect() } client = candidate - self.workspaces = workspaces + setWorkspaces(workspaces) phase = .connected startEventLoop(client: candidate) return diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift index a2e74a4762a4..fa2ab498b67b 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -83,6 +83,15 @@ public final class HiveRemoteTerminalSession { phase = .idle } + /// Re-request a full replay snapshot, e.g. after the local mirror surface + /// first applies its real size (a replay delivered to a zero-sized manual + /// surface renders nothing until the next full frame). + public func refreshReplay() { + Task { [weak self] in + try? await self?.requestReplay() + } + } + // MARK: - Input /// Send typed text to the remote PTY (`terminal.input`). diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2ed85c071f4b..1d97c7efa1c3 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -238572,6 +238572,23 @@ } } } + }, + "hive.scopePicker.allComputers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "All Computers" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべてのコンピュータ" + } + } + } } }, "version": "1.0" diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a5e9807028a1..f2d35daea0a8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9895,6 +9895,10 @@ struct VerticalTabsSidebar: View { @EnvironmentObject var sidebarUnread: SidebarUnreadModel var notificationStore: TerminalNotificationStore { .shared } @EnvironmentObject var cmuxConfigStore: CmuxConfigStore + // Computer scope for the workspace list (This Mac / All Computers / one + // device). Scope changes are rare and user-driven, so observing the + // shared model here does not add churn to typing-hot paths. + @ObservedObject var hiveScope = HiveSidebarScopeModel.shared @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -10274,7 +10278,15 @@ struct VerticalTabsSidebar: View { let _ = { minimalModeInvalidationProbe.verticalTabsSidebarBody?() }() #endif let signpost = SidebarProfilingSignposts.begin("vertical-sidebar-body", "workspaces=\(tabManager.tabs.count) selected=\(sidebarShortTabId(tabManager.selectedTabId))") - let tabs = tabManager.tabs + let scope = hiveScope.scope + let tabs = scope == .allComputers + ? tabManager.tabs + : tabManager.tabs.filter { workspace in + HiveSidebarScopeModel.isVisible( + deviceID: HiveComputerMirrorController.shared.deviceID(forWorkspace: workspace.id), + scope: scope + ) + } let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index fd16206b7194..cc096dbbb9d5 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -1,4 +1,6 @@ import CmuxHive +import CmuxMobileRPC +import CmuxTerminal import Foundation /// Presents a paired remote Mac's cmux workspaces as NATIVE local mirror @@ -11,58 +13,133 @@ import Foundation /// returns over `mobile.terminal.input`. The result is the exact cmux UI — /// sidebar rows, tab bar, pane chrome — backed by the other Mac's terminals. /// -/// v1 scope: the remote topology is snapshotted at attach (one workspace per -/// remote workspace, one tab per remote terminal); live topology reconcile -/// and remote workspace/tab creation are follow-ups. +/// Topology stays live: the controller consumes the session's +/// `workspaceUpdates()` stream and adds/removes mirror workspaces and +/// terminal tabs as the host's list changes. @MainActor final class HiveComputerMirrorController { static let shared = HiveComputerMirrorController() private init() {} - private struct DeviceMirror { - var workspaceIds: [UUID] = [] - var terminals: [HiveRemoteTerminalSession] = [] + private final class DeviceMirror { + var computerName: String = "" + weak var tabManager: TabManager? + var reconcileTask: Task? + /// Local mirror workspace id per remote workspace id. + var workspaceIdByRemoteID: [String: UUID] = [:] + /// Terminal streams per remote terminal id. + var terminalsByRemoteID: [String: HiveRemoteTerminalSession] = [:] + /// Local display-tab panel id per remote terminal id. + var panelIdByRemoteTerminalID: [String: UUID] = [:] } private var mirrorsByDeviceID: [String: DeviceMirror] = [:] - /// Whether `workspaceId` is one of this controller's mirror workspaces. - func ownsWorkspace(_ workspaceId: UUID) -> Bool { - mirrorsByDeviceID.values.contains { $0.workspaceIds.contains(workspaceId) } + /// Last remote grid dimensions applied to one mirror surface (closure + /// state; a class so the frame handler can mutate its capture). + private final class HiveMirrorAppliedDims { + var columns = 0 + var rows = 0 + } + + /// The device a mirror workspace belongs to, or `nil` for local + /// workspaces. Drives the sidebar's computer scope filter. + func deviceID(forWorkspace workspaceId: UUID) -> String? { + for (deviceID, mirror) in mirrorsByDeviceID + where mirror.workspaceIdByRemoteID.values.contains(workspaceId) { + return deviceID + } + return nil } /// Attaches (or re-focuses) a paired computer's workspaces as native - /// mirror workspaces in `tabManager`. - /// - Returns: the first mirror workspace id, or `nil` when the session - /// could not be created or the computer reported no workspaces. + /// mirror workspaces in `tabManager`, keeping them reconciled with the + /// host's live topology. @discardableResult func attach(deviceID: String, into tabManager: TabManager) async -> UUID? { - if let existing = mirrorsByDeviceID[deviceID], - let firstId = existing.workspaceIds.first, - let workspace = tabManager.workspacesById[firstId] { - tabManager.selectWorkspace(workspace) - return firstId + if let existing = mirrorsByDeviceID[deviceID] { + if let firstId = existing.workspaceIdByRemoteID.values.first, + let workspace = tabManager.workspacesById[firstId] { + tabManager.selectWorkspace(workspace) + return firstId + } + existing.reconcileTask?.cancel() + mirrorsByDeviceID.removeValue(forKey: deviceID) + } + guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID) else { + return nil + } + let mirror = DeviceMirror() + mirror.tabManager = tabManager + mirror.computerName = HiveComputersService.shared.directory?.computers + .first(where: { $0.deviceID == deviceID })?.displayName + ?? session.displayName + mirrorsByDeviceID[deviceID] = mirror + + mirror.reconcileTask = Task { @MainActor [weak self, weak mirror] in + for await workspaces in session.workspaceUpdates() { + guard let self, let mirror else { return } + self.reconcile(remote: workspaces, mirror: mirror, session: session) + } } - guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID), - let client = session.client else { return nil } - // The session connects asynchronously; wait briefly for the first - // workspace list rather than mirroring an empty snapshot. + + // Wait briefly for the first non-empty list so the caller can select + // a mirror workspace; reconciliation keeps running either way. var attempts = 0 - while session.workspaces.isEmpty, attempts < 40 { + while mirror.workspaceIdByRemoteID.isEmpty, attempts < 40 { try? await Task.sleep(for: .milliseconds(250)) attempts += 1 } - let remoteWorkspaces = session.workspaces - guard !remoteWorkspaces.isEmpty else { return nil } - let computerName = HiveComputersService.shared.directory?.computers - .first(where: { $0.deviceID == deviceID })?.displayName - ?? session.displayName + if let firstId = mirror.workspaceIdByRemoteID.values.first, + let workspace = tabManager.workspacesById[firstId] { + tabManager.selectWorkspace(workspace) + return firstId + } + return nil + } + + /// Detaches a computer's mirrors: stops the streams and closes the + /// mirror workspaces. + func detach(deviceID: String, from tabManager: TabManager) { + guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } + mirror.reconcileTask?.cancel() + for (_, terminal) in mirror.terminalsByRemoteID { terminal.detach() } + for (_, workspaceId) in mirror.workspaceIdByRemoteID { + guard let workspace = tabManager.workspacesById[workspaceId] else { continue } + tabManager.closeWorkspace(workspace) + } + } + + // MARK: - Reconcile - var mirror = DeviceMirror() - for remote in remoteWorkspaces { + private func reconcile( + remote workspaces: [HiveRemoteWorkspace], + mirror: DeviceMirror, + session: HiveRemoteMacSession + ) { + guard let tabManager = mirror.tabManager, let client = session.client else { return } + + let remoteIDs = Set(workspaces.map(\.id)) + // Remove mirrors whose remote workspace vanished (closed on host, or + // the user closed the local mirror workspace themselves). + for (remoteID, workspaceId) in mirror.workspaceIdByRemoteID { + let locallyClosed = tabManager.workspacesById[workspaceId] == nil + guard locallyClosed || !remoteIDs.contains(remoteID) else { continue } + mirror.workspaceIdByRemoteID.removeValue(forKey: remoteID) + if let workspace = tabManager.workspacesById[workspaceId] { + tabManager.closeWorkspace(workspace) + } + } + + for remote in workspaces { + if let workspaceId = mirror.workspaceIdByRemoteID[remote.id], + let workspace = tabManager.workspacesById[workspaceId] { + addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, client: client) + continue + } let title = String( localized: "hive.mirror.workspaceTitle", - defaultValue: "\(remote.title) — \(computerName)" + defaultValue: "\(remote.title) — \(mirror.computerName)" ) let workspace = tabManager.addWorkspace( title: title, @@ -75,59 +152,69 @@ final class HiveComputerMirrorController { // command routing no-ops for this workspace (no tmux mirror is // registered for it). workspace.isRemoteTmuxMirror = true + mirror.workspaceIdByRemoteID[remote.id] = workspace.id let defaultPanelIds = Array(workspace.panels.keys) - - for (index, terminal) in remote.terminals.enumerated() { - let terminalSession = HiveRemoteTerminalSession( - client: client, - workspaceID: remote.id, - terminalID: terminal.id, - retryDelay: { @Sendable attempt in - await HiveReconnectBackoff().delay(attempt: attempt) - } - ) - guard let panel = workspace.addRemoteTmuxDisplayPane( - remotePaneId: index, - title: terminal.title, - focus: false, - onInput: { data in - let text = String(decoding: data, as: UTF8.self) - guard !text.isEmpty else { return } - Task { @MainActor in terminalSession.send(text: text) } - } - ) else { continue } - terminalSession.frameBytesHandler = { [weak panel] bytes in - guard !bytes.isEmpty else { return } - panel?.surface.processRemoteOutput(bytes) - } - terminalSession.attach() - mirror.terminals.append(terminalSession) - } - - // Drop the workspace's default local shell tab; the mirror tabs - // are the workspace now (same move as the tmux mirror). + addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, client: client) for panelId in defaultPanelIds where workspace.panels[panelId] != nil { _ = workspace.removeRemoteTmuxDisplayPane(panelId) } - mirror.workspaceIds.append(workspace.id) } - guard !mirror.workspaceIds.isEmpty else { return nil } - mirrorsByDeviceID[deviceID] = mirror - if let firstId = mirror.workspaceIds.first, - let workspace = tabManager.workspacesById[firstId] { - tabManager.selectWorkspace(workspace) - } - return mirror.workspaceIds.first } - /// Detaches a computer's mirrors: stops the terminal streams and closes - /// the mirror workspaces. - func detach(deviceID: String, from tabManager: TabManager) { - guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } - for terminal in mirror.terminals { terminal.detach() } - for workspaceId in mirror.workspaceIds { - guard let workspace = tabManager.workspacesById[workspaceId] else { continue } - tabManager.closeWorkspace(workspace) + private func addMissingTerminals( + remote: HiveRemoteWorkspace, + workspace: Workspace, + mirror: DeviceMirror, + client: MobileCoreRPCClient + ) { + for (index, terminal) in remote.terminals.enumerated() + where mirror.terminalsByRemoteID[terminal.id] == nil { + let terminalSession = HiveRemoteTerminalSession( + client: client, + workspaceID: remote.id, + terminalID: terminal.id, + retryDelay: { @Sendable attempt in + await HiveReconnectBackoff().delay(attempt: attempt) + } + ) + guard let panel = workspace.addRemoteTmuxDisplayPane( + remotePaneId: index, + title: terminal.title, + focus: false, + onInput: { data in + let text = String(decoding: data, as: UTF8.self) + guard !text.isEmpty else { return } + Task { @MainActor in terminalSession.send(text: text) } + }, + onResize: { [weak terminalSession] _, _ in + // A replay delivered to a zero-sized manual surface renders + // nothing; re-request one whenever the surface (re)sizes so + // the first visible layout always paints a full frame. + terminalSession?.refreshReplay() + } + ) else { continue } + // The remote grid is authoritative for the mirror surface's cell + // dimensions; adopt them whenever they change so replay/patch rows + // land on the layout they were produced for. + let appliedDims = HiveMirrorAppliedDims() + terminalSession.frameBytesHandler = { [weak panel, weak terminalSession] (bytes: Data) in + guard let panel else { return } + if let grid = terminalSession?.grid, grid.columns > 0, grid.rows > 0, + appliedDims.columns != grid.columns || appliedDims.rows != grid.rows { + appliedDims.columns = grid.columns + appliedDims.rows = grid.rows + _ = panel.surface.applyMobileViewportLimit( + columns: grid.columns, + rows: grid.rows, + reason: "hiveMirrorFrame" + ) + } + guard !bytes.isEmpty else { return } + panel.surface.processRemoteOutput(bytes) + } + terminalSession.attach() + mirror.terminalsByRemoteID[terminal.id] = terminalSession + mirror.panelIdByRemoteTerminalID[terminal.id] = panel.id } } } diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index fde7b9d12d4a..36b78eb485ea 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -17,6 +17,36 @@ struct HiveScopeComputer: Equatable, Identifiable { let name: String } +/// Which computers' workspaces the sidebar shows — the macOS counterpart of +/// the iOS workspace-title Mac picker's scopes. +enum HiveSidebarScope: Equatable { + /// Local workspaces only (the default). + case thisMac + /// Local workspaces plus every attached computer's mirrors. + case allComputers + /// One computer's mirror workspaces only. + case device(String) +} + +/// App-wide sidebar computer scope. Global (not per-window) in v1; scope +/// changes are rare and user-driven. +@MainActor +final class HiveSidebarScopeModel: ObservableObject { + static let shared = HiveSidebarScopeModel() + @Published var scope: HiveSidebarScope = .thisMac + private init() {} + + /// Whether `workspace` is visible under the current scope, given the + /// device that owns it (`nil` for local workspaces). + nonisolated static func isVisible(deviceID: String?, scope: HiveSidebarScope) -> Bool { + switch scope { + case .thisMac: return deviceID == nil + case .allComputers: return true + case .device(let id): return deviceID == id + } + } +} + /// Bottom-of-sidebar computer scope picker, shown only in /// `computers.presentation = sidebar` mode when paired computers exist: /// switches the main window between This Mac and a remote computer's live @@ -27,38 +57,16 @@ struct HiveSidebarScopePicker: View { @LiveSetting(\.computers.presentation) private var presentation @State private var computers: [HiveScopeComputer] = [] + @ObservedObject private var scopeModel = HiveSidebarScopeModel.shared + var body: some View { if presentation == .sidebar, !computers.isEmpty { Menu { - Button { - selection = .tabs - } label: { - if activeDeviceID == nil { - Label(thisMacTitle, systemImage: "checkmark") - } else { - Text(thisMacTitle) - } - } + scopeButton(scope: .thisMac, title: thisMacTitle) + scopeButton(scope: .allComputers, title: allComputersTitle) Divider() ForEach(computers) { computer in - Button { - // Native mirrors: the computer's workspaces become - // real sidebar workspaces; selection stays on .tabs. - selection = .tabs - let manager = tabManager - Task { @MainActor in - _ = await HiveComputerMirrorController.shared.attach( - deviceID: computer.id, - into: manager - ) - } - } label: { - if activeDeviceID == computer.id { - Label(computer.name, systemImage: "checkmark") - } else { - Text(computer.name) - } - } + scopeButton(scope: .device(computer.id), title: computer.name) } } label: { Label(currentTitle, systemImage: "desktopcomputer") @@ -78,18 +86,51 @@ struct HiveSidebarScopePicker: View { } } - private var activeDeviceID: String? { - if case let .computer(deviceID) = selection { return deviceID } - return nil + @ViewBuilder + private func scopeButton(scope: HiveSidebarScope, title: String) -> some View { + Button { + selection = .tabs + scopeModel.scope = scope + let manager = tabManager + // Scoping to a computer (or all) attaches its native mirrors. + let deviceIDs: [String] + switch scope { + case .thisMac: deviceIDs = [] + case .allComputers: deviceIDs = computers.map(\.id) + case .device(let id): deviceIDs = [id] + } + guard !deviceIDs.isEmpty else { return } + Task { @MainActor in + for deviceID in deviceIDs { + _ = await HiveComputerMirrorController.shared.attach( + deviceID: deviceID, + into: manager + ) + } + } + } label: { + if scopeModel.scope == scope { + Label(title, systemImage: "checkmark") + } else { + Text(title) + } + } } private var thisMacTitle: String { String(localized: "hive.scopePicker.thisMac", defaultValue: "This Mac") } + private var allComputersTitle: String { + String(localized: "hive.scopePicker.allComputers", defaultValue: "All Computers") + } + private var currentTitle: String { - guard let activeDeviceID else { return thisMacTitle } - return computers.first(where: { $0.id == activeDeviceID })?.name ?? thisMacTitle + switch scopeModel.scope { + case .thisMac: return thisMacTitle + case .allComputers: return allComputersTitle + case .device(let id): return computers.first(where: { $0.id == id })?.name ?? thisMacTitle + } } private func observeComputers() async { From 8c2c5a19ab03a3936ac82c7f6b97031e5ff6d830 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 14:25:46 -0700 Subject: [PATCH 26/97] Hive: per-window computer scope; windows mode opens a native cmux window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HiveSidebarScopeModel is now per-window (registered by TabManager), so a computer's dedicated window scopes to that device while other windows keep their own scope. 'Open Computers In: Separate Windows' now creates a real main window, scopes its sidebar to the device, and attaches the native mirrors — replacing the old auxiliary Canvas viewer window. Co-Authored-By: Claude Fable 5 --- Sources/ContentView.swift | 9 +++++---- Sources/HostSettingsActions+Computers.swift | 18 ++++++++++++++++- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 20 ++++++++++++++----- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f2d35daea0a8..28330cf8bcb0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1606,6 +1606,7 @@ struct ContentView: View { ) }, observedWindow: observedWindow, + hiveScope: HiveSidebarScopeModel.scopeModel(for: tabManager), selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, sidebarRenderWorkerClient: $sidebarRenderWorkerClient ) @@ -9896,9 +9897,9 @@ struct VerticalTabsSidebar: View { var notificationStore: TerminalNotificationStore { .shared } @EnvironmentObject var cmuxConfigStore: CmuxConfigStore // Computer scope for the workspace list (This Mac / All Computers / one - // device). Scope changes are rare and user-driven, so observing the - // shared model here does not add churn to typing-hot paths. - @ObservedObject var hiveScope = HiveSidebarScopeModel.shared + // device). Per-window (keyed by TabManager); scope changes are rare and + // user-driven, so observing here does not add churn to typing-hot paths. + @ObservedObject var hiveScope: HiveSidebarScopeModel @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -10365,7 +10366,7 @@ struct VerticalTabsSidebar: View { extensionSidebarScrollArea(renderContext: renderContext) } VStack(alignment: .leading, spacing: 4) { - HiveSidebarScopePicker(selection: $selection) + HiveSidebarScopePicker(selection: $selection, scopeModel: hiveScope) .padding(.leading, 8) SidebarFooter( updateViewModel: updateViewModel, diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index 0713a5a6e61a..f0878ff65479 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -93,7 +93,23 @@ extension HostSettingsActions { ) context.window?.makeKeyAndOrderFront(nil) case .windows: - HiveViewerWindowController.shared.show(deviceID: deviceID) + // A real cmux window scoped to this computer: create a main + // window, scope its sidebar to the device, attach mirrors. + guard let appDelegate = AppDelegate.shared else { return } + let windowId = appDelegate.createMainWindow(shouldActivate: true) + var context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + var attempts = 0 + while context == nil, attempts < 20 { + try? await Task.sleep(for: .milliseconds(100)) + context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + attempts += 1 + } + guard let context else { return } + HiveSidebarScopeModel.scopeModel(for: context.tabManager).scope = .device(deviceID) + _ = await HiveComputerMirrorController.shared.attach( + deviceID: deviceID, + into: context.tabManager + ) } } } diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 36b78eb485ea..0df19d5c3fdd 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -28,13 +28,23 @@ enum HiveSidebarScope: Equatable { case device(String) } -/// App-wide sidebar computer scope. Global (not per-window) in v1; scope -/// changes are rare and user-driven. +/// Per-window sidebar computer scope, registered by the window's TabManager +/// so a computer's dedicated window can scope to that device while other +/// windows keep their own scope. @MainActor final class HiveSidebarScopeModel: ObservableObject { - static let shared = HiveSidebarScopeModel() @Published var scope: HiveSidebarScope = .thisMac - private init() {} + + private static var modelsByTabManager: [ObjectIdentifier: HiveSidebarScopeModel] = [:] + + /// The scope model for one window's TabManager (created on first use). + static func scopeModel(for tabManager: TabManager) -> HiveSidebarScopeModel { + let key = ObjectIdentifier(tabManager) + if let existing = modelsByTabManager[key] { return existing } + let model = HiveSidebarScopeModel() + modelsByTabManager[key] = model + return model + } /// Whether `workspace` is visible under the current scope, given the /// device that owns it (`nil` for local workspaces). @@ -57,7 +67,7 @@ struct HiveSidebarScopePicker: View { @LiveSetting(\.computers.presentation) private var presentation @State private var computers: [HiveScopeComputer] = [] - @ObservedObject private var scopeModel = HiveSidebarScopeModel.shared + @ObservedObject var scopeModel: HiveSidebarScopeModel var body: some View { if presentation == .sidebar, !computers.isEmpty { From 8b07518d0550143842db8ba62c3e304e60a66ace Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 16:09:54 -0700 Subject: [PATCH 27/97] Hive mirrors: repaint from cached full frame on selection and size events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A replay delivered before a mirror tab's ghostty surface realized rendered nothing, and idle terminals never produced another full frame — leaving blank panes. The terminal session now caches the last full frame's replacement bytes; selecting a mirror workspace (and any manual-size application) repaints every terminal from cache and re-requests a fresh replay. Co-Authored-By: Claude Fable 5 --- .../CmuxHive/HiveRemoteTerminalSession.swift | 9 ++++-- .../Hive/HiveComputerMirrorController.swift | 31 ++++++++++++++++--- Sources/TabManager.swift | 6 ++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift index fa2ab498b67b..c782acee8155 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -41,6 +41,10 @@ public final class HiveRemoteTerminalSession { /// so a manual-I/O ghostty surface can render the stream natively. The /// grid model keeps reducing in parallel for tests and fallbacks. @ObservationIgnored public var frameBytesHandler: (@MainActor (Data) -> Void)? + /// The most recent full frame as replacement bytes, kept so a surface + /// that realizes (or resizes) after the replay landed can be repainted + /// immediately without a network round-trip. + @ObservationIgnored public private(set) var lastFullFrameBytes: Data? @ObservationIgnored private let client: MobileCoreRPCClient @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void @@ -191,9 +195,10 @@ public final class HiveRemoteTerminalSession { private func applyFrame(_ frame: MobileTerminalRenderGridFrame) { grid.apply(frame) - guard let frameBytesHandler else { return } let replay = MobileTerminalRenderGridReplay(frame) - frameBytesHandler(frame.full ? replay.replacementBytes() : replay.patchBytes()) + let bytes = frame.full ? replay.replacementBytes() : replay.patchBytes() + if frame.full { lastFullFrameBytes = bytes } + frameBytesHandler?(bytes) } /// Decode one `terminal.render_grid` event payload: either the wrapped diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index cc096dbbb9d5..aca2ba01505a 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -42,6 +42,22 @@ final class HiveComputerMirrorController { var rows = 0 } + /// Repaints a mirror workspace's terminals from their cached full frames + /// and re-requests replays. Called when the workspace is selected so a + /// surface that realized after its replay landed still paints. + func workspaceSelected(_ workspaceId: UUID) { + for mirror in mirrorsByDeviceID.values + where mirror.workspaceIdByRemoteID.values.contains(workspaceId) { + for (_, terminal) in mirror.terminalsByRemoteID { + if let cached = terminal.lastFullFrameBytes { + terminal.frameBytesHandler?(cached) + } + terminal.refreshReplay() + } + return + } + } + /// The device a mirror workspace belongs to, or `nil` for local /// workspaces. Drives the sidebar's computer scope filter. func deviceID(forWorkspace workspaceId: UUID) -> String? { @@ -186,11 +202,16 @@ final class HiveComputerMirrorController { guard !text.isEmpty else { return } Task { @MainActor in terminalSession.send(text: text) } }, - onResize: { [weak terminalSession] _, _ in - // A replay delivered to a zero-sized manual surface renders - // nothing; re-request one whenever the surface (re)sizes so - // the first visible layout always paints a full frame. - terminalSession?.refreshReplay() + onResize: { [weak terminalSession, weak self] _, _ in + // A replay delivered to an unrealized/zero-sized manual + // surface renders nothing; repaint from the cached full + // frame immediately and re-request a fresh replay. + guard let terminalSession else { return } + if let cached = terminalSession.lastFullFrameBytes { + terminalSession.frameBytesHandler?(cached) + } + terminalSession.refreshReplay() + _ = self } ) else { continue } // The remote grid is authoritative for the mirror surface's cell diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1144bc80facb..e7f41b3f1e92 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2299,6 +2299,12 @@ class TabManager: ObservableObject { debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id) #endif selectWorkspaceId(workspace.id, notificationDismissalContext: .explicitWorkspaceResume) + // Hive mirror workspaces repaint from their cached frames on + // selection (a surface that realized after its replay landed would + // otherwise stay blank until the next remote output). + if workspace.isRemoteTmuxMirror { + HiveComputerMirrorController.shared.workspaceSelected(workspace.id) + } } // Keep selectTab as convenience alias From bfe96a640befd1a1fcf6a8d563322a8878c1d706 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 16:35:42 -0700 Subject: [PATCH 28/97] Hive mirrors: font-fitted scale-to-fill for mirror surfaces The mirror rendered the remote grid at a fixed pixel box (the legacy manual-IO viewport cap). Add manualIOFontFitEnabled: hive computer mirrors opt into the same font-fitted viewport path local surfaces use, so the remote columns x rows scale to fill the local pane. Remote tmux mirrors keep the legacy cap unchanged. Co-Authored-By: Claude Fable 5 --- .../Surface/TerminalSurface+MobileViewportFit.swift | 10 ++++++---- .../Sources/CmuxTerminal/Surface/TerminalSurface.swift | 5 +++++ Sources/Hive/HiveComputerMirrorController.swift | 3 +++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift index 3a1626267a9a..5616819b570c 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift @@ -65,9 +65,11 @@ extension TerminalSurface { paneHost.setMobileViewportBorder(size: nil, drawRight: false, drawBottom: false) return nil } - if manualIO { - // Remote/tmux mirrors keep legacy capping; their remote grid is - // authoritative and font fitting is intentionally out of v1 scope. + if manualIO, !manualIOFontFitEnabled { + // Remote tmux mirrors keep legacy capping; their remote grid is + // authoritative and font fitting is intentionally out of scope. + // Hive computer mirrors opt into the fitted path below so the + // remote grid scales to fill the local pane. return legacyApplyMobileViewportLimit(surface: surface, columns: columns, rows: rows, reason: reason) } mobileViewportCellLimit = (columns: max(1, columns), rows: max(1, rows)) @@ -220,7 +222,7 @@ extension TerminalSurface { ) -> MobileViewportFitResult { guard width > 0, height > 0 else { return .passthrough(width: width, height: height) } guard let mobileViewportCellLimit else { return .passthrough(width: width, height: height) } - if manualIO { + if manualIO, !manualIOFontFitEnabled { guard let limit = mobileViewportPixelLimit(for: surface) else { return .passthrough(width: width, height: height) } return .passthrough(width: min(width, limit.width), height: min(height, limit.height), grantWidth: limit.width, grantHeight: limit.height) } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 7a68d2a6da2a..1bc1c56d6874 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -179,6 +179,11 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// For MANUAL-I/O remote tmux display surfaces: whether to suppress /// ghostty primary-screen reflow on resize. var manualIONoReflow = true + /// For MANUAL-I/O mirror surfaces whose remote grid is authoritative: + /// opt into the font-fitted viewport path so the remote columns × rows + /// scale to fill the local pane (hive computer mirrors) instead of the + /// legacy fixed-pixel cap (remote tmux keeps legacy). + @MainActor public var manualIOFontFitEnabled = false /// Retained userdata for the MANUAL-mode `io_write_cb`; released alongside /// the surface. var manualIOContext: Unmanaged? diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index aca2ba01505a..69a3007e8866 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -214,6 +214,9 @@ final class HiveComputerMirrorController { _ = self } ) else { continue } + // Scale the remote grid to fill the local pane (font-fitted), + // instead of the fixed-pixel legacy cap. + panel.surface.manualIOFontFitEnabled = true // The remote grid is authoritative for the mirror surface's cell // dimensions; adopt them whenever they change so replay/patch rows // land on the layout they were produced for. From 056aa6fd07686f25a8471171d0e390ba39b79e3b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 20:10:09 -0700 Subject: [PATCH 29/97] Host: boot never-realized surfaces headless on mobile.terminal.replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A workspace never rendered since app launch has no render state, so remote viewers (phone or Mac mirror) got a valid-but-empty replay frame — blank panes for the host's background workspaces. Request a background surface start when the byte tee has no state for the requested surface; the render observer then streams real frames as it comes up. Co-Authored-By: Claude Fable 5 --- Sources/TerminalController.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 05670e3b4b98..c0c192d1eb79 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -14222,6 +14222,13 @@ class TerminalController { return .err(code: "invalid_params", message: "Invalid mobile viewport report", data: nil) } _ = applyMobileViewportReport(params: params, terminalPanel: terminalPanel, reason: "mobile.terminal.replay") + // A surface never realized this launch has no render state, so its + // replay frame is empty. Boot it headless so background workspaces + // replay real content to remote viewers (phone or Mac mirror); the + // render observer then streams frames as the surface comes up. + if MobileTerminalByteTee.shared.replayState(surfaceID: surfaceId) == nil { + terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded() + } let state = MobileTerminalByteTee.shared.replayState(surfaceID: surfaceId) let seq = state?.seq ?? 0 let renderGrid = mobileTerminalRenderGridFrame( From 74348b2a0719dd92af5407aba01cb83e786d0b59 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 16 Jul 2026 21:15:38 -0700 Subject: [PATCH 30/97] Hive mirrors: scope selection repaints, prune dead terminals, revert font-fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workspaceSelected repaints only the selected workspace's terminals (a device-wide repaint stormed replays on every click — visible lag). - Reconcile prunes terminal sessions whose remote terminal or workspace vanished (a host relaunch left dead surface ids replaying NOT_FOUND forever) and removes their display tabs. - Disable manualIOFontFitEnabled for now: after the main merge the fitted path rendered mirrors blank; the legacy cap always paints. Proper manual- surface font fit is the follow-up. Co-Authored-By: Claude Fable 5 --- .../Hive/HiveComputerMirrorController.swift | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index 69a3007e8866..d6c397b4c258 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -31,6 +31,9 @@ final class HiveComputerMirrorController { var terminalsByRemoteID: [String: HiveRemoteTerminalSession] = [:] /// Local display-tab panel id per remote terminal id. var panelIdByRemoteTerminalID: [String: UUID] = [:] + /// Remote terminal ids per remote workspace id (repaint scoping and + /// dead-terminal pruning). + var terminalIDsByRemoteWorkspaceID: [String: [String]] = [:] } private var mirrorsByDeviceID: [String: DeviceMirror] = [:] @@ -44,11 +47,16 @@ final class HiveComputerMirrorController { /// Repaints a mirror workspace's terminals from their cached full frames /// and re-requests replays. Called when the workspace is selected so a - /// surface that realized after its replay landed still paints. + /// surface that realized after its replay landed still paints. Scoped to + /// the selected workspace's terminals only (a device-wide repaint storms + /// replays on every click). func workspaceSelected(_ workspaceId: UUID) { - for mirror in mirrorsByDeviceID.values - where mirror.workspaceIdByRemoteID.values.contains(workspaceId) { - for (_, terminal) in mirror.terminalsByRemoteID { + for mirror in mirrorsByDeviceID.values { + guard let remoteWorkspaceID = mirror.workspaceIdByRemoteID + .first(where: { $0.value == workspaceId })?.key else { continue } + let terminalIDs = mirror.terminalIDsByRemoteWorkspaceID[remoteWorkspaceID] ?? [] + for terminalID in terminalIDs { + guard let terminal = mirror.terminalsByRemoteID[terminalID] else { continue } if let cached = terminal.lastFullFrameBytes { terminal.frameBytesHandler?(cached) } @@ -137,15 +145,39 @@ final class HiveComputerMirrorController { let remoteIDs = Set(workspaces.map(\.id)) // Remove mirrors whose remote workspace vanished (closed on host, or - // the user closed the local mirror workspace themselves). + // the user closed the local mirror workspace themselves), and stop + // their terminal streams so dead surface ids don't keep replaying. for (remoteID, workspaceId) in mirror.workspaceIdByRemoteID { let locallyClosed = tabManager.workspacesById[workspaceId] == nil guard locallyClosed || !remoteIDs.contains(remoteID) else { continue } mirror.workspaceIdByRemoteID.removeValue(forKey: remoteID) + for terminalID in mirror.terminalIDsByRemoteWorkspaceID[remoteID] ?? [] { + mirror.terminalsByRemoteID.removeValue(forKey: terminalID)?.detach() + mirror.panelIdByRemoteTerminalID.removeValue(forKey: terminalID) + } + mirror.terminalIDsByRemoteWorkspaceID.removeValue(forKey: remoteID) if let workspace = tabManager.workspacesById[workspaceId] { tabManager.closeWorkspace(workspace) } } + // Prune terminals that vanished from surviving remote workspaces. + for remote in workspaces { + guard let workspaceId = mirror.workspaceIdByRemoteID[remote.id], + let workspace = tabManager.workspacesById[workspaceId] else { continue } + let liveTerminalIDs = Set(remote.terminals.map(\.id)) + var kept: [String] = [] + for terminalID in mirror.terminalIDsByRemoteWorkspaceID[remote.id] ?? [] { + if liveTerminalIDs.contains(terminalID) { + kept.append(terminalID) + continue + } + mirror.terminalsByRemoteID.removeValue(forKey: terminalID)?.detach() + if let panelId = mirror.panelIdByRemoteTerminalID.removeValue(forKey: terminalID) { + _ = workspace.removeRemoteTmuxDisplayPane(panelId) + } + } + mirror.terminalIDsByRemoteWorkspaceID[remote.id] = kept + } for remote in workspaces { if let workspaceId = mirror.workspaceIdByRemoteID[remote.id], @@ -214,9 +246,10 @@ final class HiveComputerMirrorController { _ = self } ) else { continue } - // Scale the remote grid to fill the local pane (font-fitted), - // instead of the fixed-pixel legacy cap. - panel.surface.manualIOFontFitEnabled = true + // Font-fitted fill is disabled until the fit path supports + // manual surfaces reliably (post-merge it produced blank panes); + // the legacy cap renders at remote size, which always paints. + panel.surface.manualIOFontFitEnabled = false // The remote grid is authoritative for the mirror surface's cell // dimensions; adopt them whenever they change so replay/patch rows // land on the layout they were produced for. @@ -239,6 +272,7 @@ final class HiveComputerMirrorController { terminalSession.attach() mirror.terminalsByRemoteID[terminal.id] = terminalSession mirror.panelIdByRemoteTerminalID[terminal.id] = panel.id + mirror.terminalIDsByRemoteWorkspaceID[remote.id, default: []].append(terminal.id) } } } From 799cf8696b76b0fa567b4f29cf91f33c22d34cec Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 17 Jul 2026 02:18:29 -0700 Subject: [PATCH 31/97] Hive mirrors: unwedge never-focused renderers so mirror panes paint Mirror display panes had live terminal state (read-screen showed the remote content) but drew nothing. Ghostty renderers start focused=true with the display link running, and a running link means frames are produced ONLY by its vsync callbacks; a manual-IO display pane is never first responder, so no focus event ever reaches the renderer and it can sit on a dead link forever, consuming refreshes without drawing. TerminalSurface.ensureRendererDrawing() forces the transition out of that state: realize the renderer, assert the display id from the view's current screen, and force a focus round-trip (unfocused renderers stop the link and draw change-driven; focused panes restart the link on the asserted display). The mirror controller kicks it on first frame, on pane layout, and on workspace selection. Also: - HiveComputerMirrorController.presentViewer: one shared action path behind the Settings Open button, the sidebar scope picker, and the new hive.open RPC (windows/sidebar presentation honored). - hive.open + hive.render_probe control-socket RPCs so the whole attach/paint loop is verifiable headlessly (cmux rpc hive.open ...). - HiveComputersService.makeViewerSession refreshes the directory when the device is not listed yet (fresh-launch hive.open ordering). Co-Authored-By: Claude Fable 5 --- ...ControlCommandCoordinator+MobileHost.swift | 4 + .../MobileHost/ControlMobileHostContext.swift | 16 ++++ .../Surface/TerminalSurface+Renderer.swift | 55 ++++++++++++ .../Hive/HiveComputerMirrorController.swift | 86 ++++++++++++++++++- Sources/Hive/HiveComputersService.swift | 11 ++- Sources/HostSettingsActions+Computers.swift | 51 +---------- ...lController+ControlMobileHostContext.swift | 30 +++++++ 7 files changed, 200 insertions(+), 53 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index bfdcbd4d3077..0eab4f1f6eec 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -53,6 +53,10 @@ extension ControlCommandCoordinator { return context?.controlMobileTerminalPaste(params: request.params) case "chat.sessions.dump": return context?.controlMobileChatSessionsDump() + case "hive.open": + return context?.controlHiveOpen(params: request.params) + case "hive.render_probe": + return context?.controlHiveRenderProbe() default: return nil } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index ecec781834ac..6c32df0e1221 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -122,4 +122,20 @@ public protocol ControlMobileHostContext: AnyObject { /// /// - Returns: The fully-built command result. func controlMobileChatSessionsDump() -> ControlCallResult + + /// `hive.open` (local control socket) — open a paired computer's viewer + /// through the same shared action path as the Settings "Open" button and + /// the sidebar scope picker. Kicks the (async) presentation off and + /// returns immediately. + /// + /// - Parameter params: The decoded request params (`device_id`). + /// - Returns: The fully-built command result. + func controlHiveOpen(params: [String: JSONValue]) -> ControlCallResult + + /// `hive.render_probe` (local debug socket) — per-terminal renderer-state + /// summaries across every main window, for diagnosing panes whose terminal + /// state is live but whose renderer never draws. + /// + /// - Returns: The fully-built command result. + func controlHiveRenderProbe() -> ControlCallResult } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index d479f70b505e..f0bbe72ac7a6 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -152,4 +152,59 @@ extension TerminalSurface { } #endif } + + /// Make sure this surface's renderer is actually producing frames. + /// + /// A surface that lives in a window that has never been key — a mirror + /// viewer window opened behind Settings, or a manual-IO display pane the + /// user never focuses — can have live terminal state and a realized view + /// yet draw nothing: `setFocus(true)` is the only path that asserts the + /// display id, so Ghostty's per-surface display link may never start. + /// This re-realizes a released renderer, asserts the display id from the + /// view's current screen so the display link can start without a focus + /// event, and requests a refresh. + @MainActor + public func ensureRendererDrawing() { +#if os(macOS) + guard let surface = liveSurfaceForGhosttyAccess(reason: "renderer.ensureDrawing") else { return } + realizeRenderer() + if let view = attachedView, + let displayID = (view.window?.screen ?? NSScreen.main)?.displayID, + displayID != 0 { + ghostty_surface_set_display_id(surface, displayID) + } + // Ghostty renderers start `focused = true` with the display link + // running, and a running link means draws happen ONLY on its vsync + // callbacks. A surface that never receives a focus event (a mirror + // display pane is never first responder) can therefore sit on a dead + // link forever: refreshes are consumed but nothing is drawn. Force a + // focus transition to leave that state — unfocused renderers stop the + // link and draw change-driven; a genuinely focused pane gets the link + // restarted on the display id asserted above. + ghostty_surface_set_focus(surface, false) + if desiredFocusState { + ghostty_surface_set_focus(surface, true) + } + ghostty_surface_refresh(surface) +#endif + } + + /// One-line renderer-state summary for the `hive.render_probe` debug RPC: + /// runtime/realized/visibility flags, manual-IO, the live grid and pixel + /// size, and the display id the surface's window resolves to. + @MainActor + public func rendererDebugSummary() -> String { + var size = "none" + if let surface = liveSurfaceForGhosttyAccess(reason: "renderer.debugSummary") { + let s = ghostty_surface_size(surface) + size = "\(s.columns)x\(s.rows)@\(s.width_px)x\(s.height_px)px" + } + var display = "none" + if let view = attachedView, let id = view.window?.screen?.displayID { + display = "\(id)" + } + return "runtime=\(surface != nil ? 1 : 0) realized=\(rendererRealized ? 1 : 0) " + + "portalVisible=\(rendererPortalVisible ? 1 : 0) manualIO=\(manualIO ? 1 : 0) " + + "focusDesired=\(desiredFocusState ? 1 : 0) size=\(size) display=\(display)" + } } diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index d6c397b4c258..7d906b00092c 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -1,5 +1,6 @@ import CmuxHive import CmuxMobileRPC +import CmuxSettings import CmuxTerminal import Foundation @@ -45,6 +46,12 @@ final class HiveComputerMirrorController { var rows = 0 } + /// Weak panel holder so closures passed INTO `addRemoteTmuxDisplayPane` + /// (which creates the panel) can reference the created panel afterwards. + private final class HiveMirrorPanelBox { + weak var panel: TerminalPanel? + } + /// Repaints a mirror workspace's terminals from their cached full frames /// and re-requests replays. Called when the workspace is selected so a /// surface that realized after its replay landed still paints. Scoped to @@ -57,6 +64,11 @@ final class HiveComputerMirrorController { let terminalIDs = mirror.terminalIDsByRemoteWorkspaceID[remoteWorkspaceID] ?? [] for terminalID in terminalIDs { guard let terminal = mirror.terminalsByRemoteID[terminalID] else { continue } + if let panelId = mirror.panelIdByRemoteTerminalID[terminalID], + let workspace = mirror.tabManager?.workspacesById[workspaceId], + let panel = workspace.panels[panelId] as? TerminalPanel { + panel.surface.ensureRendererDrawing() + } if let cached = terminal.lastFullFrameBytes { terminal.frameBytesHandler?(cached) } @@ -76,6 +88,64 @@ final class HiveComputerMirrorController { return nil } + /// Open a paired computer's viewer honoring the `computers.presentation` + /// setting. This is the ONE shared action path behind every entrypoint + /// (Settings "Open" button, sidebar scope picker, `hive.open` RPC): + /// sidebar mode attaches mirrors into the key main window's sidebar; + /// windows mode creates a real main window scoped to the device. + static func presentViewer(deviceID: String) { + Task { @MainActor in + cmuxDebugLog("hive.presentViewer.begin device=\(deviceID.prefix(8))") + let presentation: ComputersPresentationMode + if let runtime = AppDelegate.shared?.settingsRuntime { + presentation = await runtime.jsonStore.value(for: runtime.catalog.computers.presentation) + } else { + presentation = .windows + } + cmuxDebugLog("hive.presentViewer.mode \(presentation)") + switch presentation { + case .sidebar: + guard let appDelegate = AppDelegate.shared, + let context = appDelegate.mainWindowContexts.values.first(where: { $0.window?.isKeyWindow == true }) + ?? appDelegate.mainWindowContexts.values.first(where: { $0.window != nil }) + else { + HiveViewerWindowController.shared.show(deviceID: deviceID) + return + } + // Native mirrors: the computer's workspaces join the main + // sidebar as real workspaces. + context.sidebarSelectionState.selection = .tabs + _ = await HiveComputerMirrorController.shared.attach( + deviceID: deviceID, + into: context.tabManager + ) + context.window?.makeKeyAndOrderFront(nil) + case .windows: + // A real cmux window scoped to this computer: create a main + // window, scope its sidebar to the device, attach mirrors. + guard let appDelegate = AppDelegate.shared else { return } + let windowId = appDelegate.createMainWindow(shouldActivate: true) + var context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + var attempts = 0 + while context == nil, attempts < 20 { + try? await Task.sleep(for: .milliseconds(100)) + context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + attempts += 1 + } + guard let context else { + cmuxDebugLog("hive.presentViewer.windowContextMissing windowId=\(windowId)") + return + } + HiveSidebarScopeModel.scopeModel(for: context.tabManager).scope = .device(deviceID) + let attached = await HiveComputerMirrorController.shared.attach( + deviceID: deviceID, + into: context.tabManager + ) + cmuxDebugLog("hive.presentViewer.attached workspace=\(attached?.uuidString.prefix(8) ?? "nil")") + } + } + } + /// Attaches (or re-focuses) a paired computer's workspaces as native /// mirror workspaces in `tabManager`, keeping them reconciled with the /// host's live topology. @@ -91,6 +161,7 @@ final class HiveComputerMirrorController { mirrorsByDeviceID.removeValue(forKey: deviceID) } guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID) else { + cmuxDebugLog("hive.mirror.attach.noSession device=\(deviceID.prefix(8))") return nil } let mirror = DeviceMirror() @@ -225,6 +296,7 @@ final class HiveComputerMirrorController { await HiveReconnectBackoff().delay(attempt: attempt) } ) + let panelBox = HiveMirrorPanelBox() guard let panel = workspace.addRemoteTmuxDisplayPane( remotePaneId: index, title: terminal.title, @@ -234,18 +306,23 @@ final class HiveComputerMirrorController { guard !text.isEmpty else { return } Task { @MainActor in terminalSession.send(text: text) } }, - onResize: { [weak terminalSession, weak self] _, _ in + onResize: { [weak terminalSession] _, _ in // A replay delivered to an unrealized/zero-sized manual // surface renders nothing; repaint from the cached full - // frame immediately and re-request a fresh replay. + // frame immediately and re-request a fresh replay. The + // resize is also the first moment the view is laid out in + // its window, so kick the renderer here — a mirror window + // that never becomes key gets no focus event, and focus is + // otherwise the only path that starts the display link. + panelBox.panel?.surface.ensureRendererDrawing() guard let terminalSession else { return } if let cached = terminalSession.lastFullFrameBytes { terminalSession.frameBytesHandler?(cached) } terminalSession.refreshReplay() - _ = self } ) else { continue } + panelBox.panel = panel // Font-fitted fill is disabled until the fit path supports // manual surfaces reliably (post-merge it produced blank panes); // the legacy cap renders at remote size, which always paints. @@ -265,6 +342,9 @@ final class HiveComputerMirrorController { rows: grid.rows, reason: "hiveMirrorFrame" ) + // First frame (or a remote resize): make sure the renderer + // is actually producing frames — see onResize above. + panel.surface.ensureRendererDrawing() } guard !bytes.isEmpty else { return } panel.surface.processRemoteOutput(bytes) diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index d5e0cbacc742..fecc3335eb2f 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -54,9 +54,16 @@ final class HiveComputersService { /// offers Open on paired rows) or auth is not configured. func makeViewerSession(deviceID: String) async -> HiveRemoteMacSession? { guard let auth, let directory else { return nil } - guard let computer = directory.computers.first(where: { $0.deviceID == deviceID }) else { - return nil + var computer = directory.computers.first(where: { $0.deviceID == deviceID }) + if computer == nil { + // On a fresh launch the directory is empty until something (the + // Settings pane, the scope picker) refreshes it. An open request + // arriving first — relaunch restore, `hive.open` RPC — must not + // fail on that ordering; load the pairings/registry and re-check. + await directory.refresh() + computer = directory.computers.first(where: { $0.deviceID == deviceID }) } + guard let computer else { return nil } // Prefer the freshest routes: a live online instance's advertised set, // falling back to whatever the pairing/registry row carries. guard let best = computer.bestPairingRoutes else { return nil } diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index f0878ff65479..25a7de0e55dc 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -64,54 +64,9 @@ extension HostSettingsActions { } func openComputerViewer(deviceID: String) { - // Honor the presentation setting: sidebar mode routes the key main - // window's content to the embedded computer page (same page the - // sidebar scope picker drives); windows mode opens/focuses the - // per-computer auxiliary viewer window. - Task { @MainActor in - let presentation: ComputersPresentationMode - if let runtime = AppDelegate.shared?.settingsRuntime { - presentation = await runtime.jsonStore.value(for: runtime.catalog.computers.presentation) - } else { - presentation = .windows - } - switch presentation { - case .sidebar: - guard let appDelegate = AppDelegate.shared, - let context = appDelegate.mainWindowContexts.values.first(where: { $0.window?.isKeyWindow == true }) - ?? appDelegate.mainWindowContexts.values.first(where: { $0.window != nil }) - else { - HiveViewerWindowController.shared.show(deviceID: deviceID) - return - } - // Native mirrors: the computer's workspaces join the main - // sidebar as real workspaces. - context.sidebarSelectionState.selection = .tabs - _ = await HiveComputerMirrorController.shared.attach( - deviceID: deviceID, - into: context.tabManager - ) - context.window?.makeKeyAndOrderFront(nil) - case .windows: - // A real cmux window scoped to this computer: create a main - // window, scope its sidebar to the device, attach mirrors. - guard let appDelegate = AppDelegate.shared else { return } - let windowId = appDelegate.createMainWindow(shouldActivate: true) - var context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } - var attempts = 0 - while context == nil, attempts < 20 { - try? await Task.sleep(for: .milliseconds(100)) - context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } - attempts += 1 - } - guard let context else { return } - HiveSidebarScopeModel.scopeModel(for: context.tabManager).scope = .device(deviceID) - _ = await HiveComputerMirrorController.shared.attach( - deviceID: deviceID, - into: context.tabManager - ) - } - } + // One shared action path with the sidebar scope picker and the + // `hive.open` RPC; honors the `computers.presentation` setting. + HiveComputerMirrorController.presentViewer(deviceID: deviceID) } /// Mints a short-lived 6-digit pairing code and advertises it through the diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index f550acde844b..69f2b607937c 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -65,6 +65,36 @@ extension TerminalController: ControlMobileHostContext { bridgeMobileResult(v2ChatSessionsDump()) } + func controlHiveOpen(params: [String: JSONValue]) -> ControlCallResult { + let foundation = foundationParams(params) + guard let deviceID = foundation["device_id"] as? String, !deviceID.isEmpty else { + return .err(code: "invalid_params", message: "Missing device_id", data: nil) + } + // Same shared action path as the Settings "Open" button and the + // sidebar scope picker; presentation happens asynchronously. + HiveComputerMirrorController.presentViewer(deviceID: deviceID) + return .ok(.object(["started": .bool(true)])) + } + + func controlHiveRenderProbe() -> ControlCallResult { + var lines: [String] = [] + let contexts = AppDelegate.shared.map { Array($0.mainWindowContexts.values) } ?? [] + for context in contexts { + let windowKey = context.window?.isKeyWindow == true ? 1 : 0 + for workspace in context.tabManager.tabs { + for (panelId, panel) in workspace.panels { + guard let terminal = panel as? TerminalPanel else { continue } + lines.append( + "window=\(context.windowId.uuidString.prefix(8)) key=\(windowKey) " + + "workspace=\"\(workspace.title)\" panel=\(panelId.uuidString.prefix(8)) " + + terminal.surface.rendererDebugSummary() + ) + } + } + } + return .ok(.object(["surfaces": .array(lines.map { .string($0) })])) + } + /// Reconstructs the legacy `[String: Any]` params from the coordinator's /// typed params. This is the exact inverse of the dispatcher's /// `request.params.mapValues { $0.foundationObject }`, so the legacy body From 90ff3a58df95279a0a4cd9dc08e8c847d1db85fe Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 21 Jul 2026 22:45:07 -0700 Subject: [PATCH 32/97] Hive mirrors: show connection status instead of a blank sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a computer whose mirror attach can't reach the remote Mac left the sidebar completely empty with zero indication why: HiveComputerMirrorController.attach(deviceID:into:) waits up to ~10s for the remote workspace list, then gives up silently if it never arrives — no error state anywhere. The window's own default local workspace also gets filtered out of view (it belongs to no device), so a device-scoped window with a failed attach renders nothing at all. HiveSidebarConnectionStatusView now fills that gap: when the sidebar's scope is `.device(id)` and the filtered tab list is empty, it reads the device's live HiveRemoteMacSession.Phase (already tracked, just never surfaced) and shows "Connecting…", "Couldn't Connect" with the real error and a Retry button, or "No open workspaces" for a connected-but- empty remote — reusing the exact localized strings and phase mapping HiveViewerRootView already uses for the standalone viewer window. `.allComputers` with zero tabs is left alone (that's "no computers paired", a different and legitimate empty state). HiveComputersService gains connectionPhase(deviceID:) (sync read of the cached session's phase) and retryConnection(deviceID:) (forces a fresh connect() without redoing the full attach dance) to back it. Also: hive.sidebar_probe control-socket RPC — per-window scope, tab counts, and the resolved status for any device-scoped empty window — verifies this fix headlessly. Confirmed live against the mini (currently unreachable): the probe resolved to `failed(message: "...connectionRefused")` within 4s and stayed stable. Co-Authored-By: Claude Fable 5 --- ...ControlCommandCoordinator+MobileHost.swift | 2 + .../MobileHost/ControlMobileHostContext.swift | 8 ++ Resources/Localizable.xcstrings | 17 ++++ Sources/ContentView.swift | 23 +++++- Sources/Hive/HiveComputersService.swift | 18 +++++ .../HiveSidebarConnectionStatusView.swift | 79 +++++++++++++++++++ ...lController+ControlMobileHostContext.swift | 32 ++++++++ cmux.xcodeproj/project.pbxproj | 4 + 8 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 Sources/Hive/HiveSidebarConnectionStatusView.swift diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index 0eab4f1f6eec..7a4ebe826df4 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -57,6 +57,8 @@ extension ControlCommandCoordinator { return context?.controlHiveOpen(params: request.params) case "hive.render_probe": return context?.controlHiveRenderProbe() + case "hive.sidebar_probe": + return context?.controlHiveSidebarProbe() default: return nil } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift index 6c32df0e1221..f924bdfd11aa 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -138,4 +138,12 @@ public protocol ControlMobileHostContext: AnyObject { /// /// - Returns: The fully-built command result. func controlHiveRenderProbe() -> ControlCallResult + + /// `hive.sidebar_probe` (local debug socket) — per-window sidebar scope, + /// tab counts, and the resolved connection-status text for any + /// device-scoped window with zero visible tabs, for diagnosing a blank + /// sidebar without needing Screen Recording permission. + /// + /// - Returns: The fully-built command result. + func controlHiveSidebarProbe() -> ControlCallResult } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7fbdcb6b389d..7425e9a0c7b7 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -239513,6 +239513,23 @@ } } } + }, + "hive.sidebar.status.noWorkspaces": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No open workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開いているワークスペースがありません" + } + } + } } }, "version": "1.0" diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 6f3f4e1048f5..1b10fd4c0c3e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10374,8 +10374,29 @@ struct VerticalTabsSidebar: View { visibleWorkspaceRowIds: visibleWorkspaceRowIds ) let _ = SidebarProfilingSignposts.end(signpost) + // A device-scoped sidebar with zero mirrored workspaces is otherwise + // indistinguishable from "genuinely empty" — show why (connecting, + // couldn't connect, or connected-but-empty) instead of a blank list. + // `.allComputers` with zero tabs is left alone: that's the normal + // "no computers paired yet" state, not a per-device connection + // problem, so it keeps the existing (no message) empty area. + let deviceScopeStatus: (deviceID: String, deviceName: String, status: HiveSidebarConnectionStatusView.Status)? = { + guard case .device(let deviceID) = scope, tabs.isEmpty else { return nil } + let deviceName = HiveComputersService.shared.directory?.computers + .first(where: { $0.deviceID == deviceID })?.displayName ?? deviceID + let phase = HiveComputersService.shared.connectionPhase(deviceID: deviceID) + return (deviceID, deviceName, HiveSidebarConnectionStatusView.Status(phase: phase)) + }() ZStack(alignment: .bottomLeading) { - if CmuxExtensionSidebarSelection.resolvesToDefaultSidebar(effectiveProviderId: effectiveExtensionSidebarProviderId) { + if let deviceScopeStatus { + HiveSidebarConnectionStatusView( + deviceName: deviceScopeStatus.deviceName, + status: deviceScopeStatus.status, + onRetry: { + HiveComputersService.shared.retryConnection(deviceID: deviceScopeStatus.deviceID) + } + ) + } else if CmuxExtensionSidebarSelection.resolvesToDefaultSidebar(effectiveProviderId: effectiveExtensionSidebarProviderId) { workspaceScrollArea(renderContext: renderContext) } else { extensionSidebarScrollArea(renderContext: renderContext) diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index fecc3335eb2f..7d5dd4e96227 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -110,6 +110,24 @@ final class HiveComputersService { await session.disconnect() } + /// The live connection phase for a device's embedded viewer session + /// (Settings Open, sidebar scope picker, or `hive.open`), if one has been + /// created. `nil` before any attach attempt — callers treat that as "never + /// tried" rather than "failed". `HiveRemoteMacSession` is `@Observable`, + /// so reading `.phase` from a SwiftUI `body` tracks it like any other + /// observable property; no polling needed. + func connectionPhase(deviceID: String) -> HiveRemoteMacSession.Phase? { + embeddedSessions[deviceID]?.phase + } + + /// Forces a fresh connection attempt for a device's embedded viewer + /// session (sidebar "Retry" button). No-ops if no session exists yet — + /// that only happens before the first attach, which already retries on + /// its own. + func retryConnection(deviceID: String) { + embeddedSessions[deviceID]?.connect() + } + /// Dev builds may pair over loopback so two instances on one machine can /// dogfood Mac-to-Mac viewing; release builds never dial themselves. private static var allowsLoopbackPairing: Bool { diff --git a/Sources/Hive/HiveSidebarConnectionStatusView.swift b/Sources/Hive/HiveSidebarConnectionStatusView.swift new file mode 100644 index 000000000000..5058552c142b --- /dev/null +++ b/Sources/Hive/HiveSidebarConnectionStatusView.swift @@ -0,0 +1,79 @@ +import CmuxHive +import SwiftUI + +/// Placeholder shown in place of the workspace list when the sidebar is +/// scoped to a single paired computer (``HiveSidebarScope/device(_:)``) and +/// has zero mirrored workspaces to show. Without this, an offline or still- +/// connecting computer renders a fully blank sidebar with no indication of +/// why — the mirror attach loop +/// (`HiveComputerMirrorController.attach(deviceID:into:)`) gives up silently +/// after ~10s, and the sidebar's scope filter then has nothing to display. +/// +/// Reuses the same localized strings and phase mapping as +/// `HiveViewerRootView` in CmuxHiveUI (the standalone viewer window's +/// connecting/failed states) so both surfaces read identically. +struct HiveSidebarConnectionStatusView: View { + enum Status: Equatable { + /// No attach attempt has been observed yet. + case neverAttempted + case connecting + case failed(message: String) + /// The session is connected but the remote genuinely has zero + /// workspaces (or the user closed every mirrored one locally). + case connectedEmpty + + init(phase: HiveRemoteMacSession.Phase?) { + switch phase { + case nil, .idle: + self = .neverAttempted + case .connecting, .reconnecting: + self = .connecting + case .connected: + self = .connectedEmpty + case .failed(let message): + self = .failed(message: message) + } + } + } + + let deviceName: String + let status: Status + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 10) { + Text(deviceName) + .font(.headline) + .foregroundStyle(.secondary) + switch status { + case .neverAttempted, .connecting: + ProgressView() + .controlSize(.small) + Text(String(localized: "hive.viewer.connecting", defaultValue: "Connecting…")) + .font(.callout) + .foregroundStyle(.secondary) + case .failed(let message): + Image(systemName: "wifi.exclamationmark") + .font(.title2) + .foregroundStyle(.secondary) + Text(String(localized: "hive.viewer.failed.title", defaultValue: "Couldn't Connect")) + .font(.callout) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Button(String(localized: "hive.viewer.retry", defaultValue: "Retry"), action: onRetry) + .controlSize(.small) + case .connectedEmpty: + Text(String(localized: "hive.sidebar.status.noWorkspaces", defaultValue: "No open workspaces")) + .font(.callout) + .foregroundStyle(.secondary) + } + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .multilineTextAlignment(.center) + .accessibilityIdentifier("HiveSidebarConnectionStatus") + } +} diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index 69f2b607937c..05aa3b60887e 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -95,6 +95,38 @@ extension TerminalController: ControlMobileHostContext { return .ok(.object(["surfaces": .array(lines.map { .string($0) })])) } + /// `hive.sidebar_probe` (local debug socket) — per-window sidebar scope, + /// tab counts, and (for a device-scoped window with zero visible tabs) + /// the resolved connection-status text, mirroring exactly what + /// `ContentView`'s sidebar body computes. Verifies the blank-sidebar fix + /// headlessly, without needing Screen Recording permission. + func controlHiveSidebarProbe() -> ControlCallResult { + var lines: [String] = [] + let contexts = AppDelegate.shared.map { Array($0.mainWindowContexts.values) } ?? [] + for context in contexts { + let tabManager = context.tabManager + let scope = HiveSidebarScopeModel.scopeModel(for: tabManager).scope + let allTabs = tabManager.tabs + let visibleTabs = scope == .allComputers + ? allTabs + : allTabs.filter { + HiveSidebarScopeModel.isVisible( + deviceID: HiveComputerMirrorController.shared.deviceID(forWorkspace: $0.id), + scope: scope + ) + } + var line = "window=\(context.windowId.uuidString.prefix(8)) scope=\(scope) " + + "allTabs=\(allTabs.count) visibleTabs=\(visibleTabs.count)" + if case .device(let deviceID) = scope, visibleTabs.isEmpty { + let phase = HiveComputersService.shared.connectionPhase(deviceID: deviceID) + let status = HiveSidebarConnectionStatusView.Status(phase: phase) + line += " deviceID=\(deviceID.prefix(8)) phase=\(String(describing: phase)) resolvedStatus=\(status)" + } + lines.append(line) + } + return .ok(.object(["windows": .array(lines.map { .string($0) })])) + } + /// Reconstructs the legacy `[String: Any]` params from the coordinator's /// typed params. This is the exact inverse of the dispatcher's /// `request.params.mapValues { $0.foundationObject }`, so the legacy body diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2d7f9e55bca9..df1dc3741f61 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -922,6 +922,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 4931A11B0000000000000002 /* HiddenRightSidebarContentMountingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */; }; C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */; }; C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D2 /* HiveComputersService.swift */; }; + 7DD3FE43EB968D44E5652135 /* HiveSidebarConnectionStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */; }; C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D5 /* HiveViewerWindowController.swift */; }; CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */; }; @@ -2911,6 +2912,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 4931A11B0000000000000001 /* HiddenRightSidebarContentMountingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HiddenRightSidebarContentMountingTests.swift; sourceTree = ""; }; C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputerMirrorController.swift; sourceTree = ""; }; C41FE00000000000000000D2 /* HiveComputersService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputersService.swift; sourceTree = ""; }; + 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveSidebarConnectionStatusView.swift; sourceTree = ""; }; C41FE00000000000000000D5 /* HiveViewerWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveViewerWindowController.swift; sourceTree = ""; }; CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/HostedInspectorDockControlScript.swift; sourceTree = ""; }; @@ -4241,6 +4243,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C41FE00000000000000000D2 /* HiveComputersService.swift */, C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */, C41FE00000000000000000D5 /* HiveViewerWindowController.swift */, + 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */, ); name = Hive; path = Hive; @@ -7063,6 +7066,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = F5320000A1B2C3D4E5F60718 /* HermesAgentIndex.swift in Sources */, C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */, C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */, + 7DD3FE43EB968D44E5652135 /* HiveSidebarConnectionStatusView.swift in Sources */, C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */, From 2d610ebf2e6fcd4784476aa8451584d52861895a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 17:21:58 -0700 Subject: [PATCH 33/97] Hive viewer: close lifecycle and rendering review gaps --- .../ControlCommandContextTestStubs.swift | 3 + .../Sources/CmuxHive/HiveComputer.swift | 4 + .../CmuxHive/HiveComputerDirectory.swift | 236 +++++++++--------- .../CmuxHive/HiveComputerRowBuilder.swift | 166 ++++++++++++ .../CmuxHive/HiveReconnectBackoff.swift | 14 +- .../CmuxHive/HiveRemoteMacSession.swift | 67 +++-- .../HiveRemoteRenderGridDecoder.swift | 21 ++ .../CmuxHive/HiveRemoteTerminalSession.swift | 166 +++++++++--- .../HiveRemoteTerminalSessionError.swift | 6 + .../CmuxHive/HiveRemoteWorkspace.swift | 4 +- .../CmuxHive/HiveRemoteWorkspaceDecoder.swift | 25 ++ .../CmuxHive/HiveTerminalGridModel.swift | 23 +- .../HiveComputerDirectoryTests.swift | 32 ++- .../HiveRemoteSessionTests.swift | 20 +- .../HiveTerminalGridModelTests.swift | 33 ++- .../CmuxHiveTests/ScriptedHostTransport.swift | 1 + .../CmuxHiveTests/SequencedRegistry.swift | 20 ++ .../CmuxHiveUI/HiveRemoteTerminalPane.swift | 2 +- .../CmuxHiveUI/HiveTerminalGridMetrics.swift | 35 +++ .../HiveTerminalGridMetricsCache.swift | 13 + .../CmuxHiveUI/HiveTerminalGridView.swift | 71 +++--- .../CmuxHiveUI/HiveTerminalInputView.swift | 173 ++++++++++++- .../CmuxHiveUI/HiveViewerRootView.swift | 51 +++- .../Sections/ComputersSection.swift | 7 +- Resources/Localizable.xcstrings | 17 ++ Sources/Auth/HostAccountFlow.swift | 1 + Sources/ContentView+AuthCommandPalette.swift | 1 + Sources/ContentView.swift | 2 +- .../Hive/HiveComputerMirrorController.swift | 72 ++++-- Sources/Hive/HiveComputersService.swift | 24 +- .../HiveSidebarConnectionStatusView.swift | 13 +- Sources/Hive/HiveViewerWindowController.swift | 10 + Sources/HostSettingsActions+Computers.swift | 2 +- Sources/TabManager.swift | 2 + ...lController+ControlMobileHostContext.swift | 2 +- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 11 - scripts/lint-ios-package-conventions.sh | 2 +- 37 files changed, 1064 insertions(+), 288 deletions(-) create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerRowBuilder.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridDecoder.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspaceDecoder.swift create mode 100644 Packages/macOS/CmuxHive/Tests/CmuxHiveTests/SequencedRegistry.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetrics.swift create mode 100644 Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetricsCache.swift diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 62a629eeb59c..da9fb4c46a34 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -513,4 +513,7 @@ extension ControlMobileHostContext { func controlMobileTerminalMouse(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileTerminalPaste(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileChatSessionsDump() -> ControlCallResult { mobileHostStubResult } + func controlHiveOpen(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlHiveRenderProbe() -> ControlCallResult { mobileHostStubResult } + func controlHiveSidebarProbe() -> ControlCallResult { mobileHostStubResult } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift index 041de34697b0..13a12fa9db9f 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift @@ -31,6 +31,10 @@ public struct HiveComputer: Equatable, Sendable, Identifiable { public var id: String { deviceID } + /// Whether this row has authenticated registry provenance. Local-only + /// pasted-link records deliberately return false and stay loopback-trusted. + public var isRegistryBacked: Bool { platform != nil } + /// Creates a merged computer row. public init( deviceID: String, diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index 6bd601f09320..c77a4c11376f 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -1,7 +1,7 @@ internal import CMUXMobileCore public import CmuxMobilePairedMac public import CmuxMobileShell -public import CmuxMobileShellModel +internal import CmuxMobileShellModel public import Foundation public import Observation @@ -34,12 +34,19 @@ public final class HiveComputerDirectory { @ObservationIgnored private let linkDecoder: HivePairingLinkDecoder @ObservationIgnored private let now: @Sendable () -> Date @ObservationIgnored private let presenceRetryDelay: @Sendable (_ attempt: Int) async -> Void + @ObservationIgnored private let rowBuilder: HiveComputerRowBuilder @ObservationIgnored private var presenceMap = PresenceMap() @ObservationIgnored private var registryDevices: [RegistryDevice] = [] @ObservationIgnored private var pairedRecords: [MobilePairedMac] = [] + @ObservationIgnored private var registryByID: [String: RegistryDevice] = [:] + @ObservationIgnored private var pairedByID: [String: MobilePairedMac] = [:] + @ObservationIgnored private var pairedRecordsByID: [String: [MobilePairedMac]] = [:] + @ObservationIgnored private var loadedScope: HiveAccountScope? + @ObservationIgnored private var scopeGeneration = 0 @ObservationIgnored private var listeners: [UUID: AsyncStream<[HiveComputer]>.Continuation] = [:] @ObservationIgnored private var presenceTask: Task? + @ObservationIgnored private var presenceGeneration = 0 /// Creates a directory over injected source seams. /// @@ -75,6 +82,7 @@ public final class HiveComputerDirectory { self.linkDecoder = linkDecoder self.now = now self.presenceRetryDelay = presenceRetryDelay + self.rowBuilder = HiveComputerRowBuilder(ownDeviceID: ownDeviceID) } // MARK: - Observation @@ -104,8 +112,10 @@ public final class HiveComputerDirectory { private func removeListener(id: UUID) { listeners.removeValue(forKey: id) if listeners.isEmpty { + presenceGeneration &+= 1 presenceTask?.cancel() presenceTask = nil + invalidatePresence() } } @@ -119,17 +129,28 @@ public final class HiveComputerDirectory { isRefreshing = true defer { isRefreshing = false } let scope = await scopeProvider() + let generation = activateScope(scope) switch await registry.listDevices() { case .ok(let devices): + guard await isCurrentScope(scope, generation: generation) else { return } registryDevices = devices + registryByID = Dictionary( + devices.map { ($0.deviceId, $0) }, + uniquingKeysWith: { first, _ in first } + ) lastRefreshFailed = false case .authRejected: + guard await isCurrentScope(scope, generation: generation) else { return } registryDevices = [] + registryByID = [:] lastRefreshFailed = false case .transientFailure: + guard await isCurrentScope(scope, generation: generation) else { return } lastRefreshFailed = true } + guard await isCurrentScope(scope, generation: generation) else { return } await reloadPairedRecords(scope: scope) + guard await isCurrentScope(scope, generation: generation) else { return } rebuild() } @@ -139,17 +160,56 @@ public final class HiveComputerDirectory { stackUserID: scope.stackUserID, teamID: scope.teamID ) + pairedByID = rowBuilder.indexPairedRecords(pairedRecords) + pairedRecordsByID = Dictionary(grouping: pairedRecords, by: \.macDeviceID) } catch { // Keep the previous local list; a store read failure must not // wipe rows the registry no longer needs to confirm. } } + private func activateScope(_ scope: HiveAccountScope) -> Int { + guard loadedScope != scope else { return scopeGeneration } + loadedScope = scope + scopeGeneration &+= 1 + registryDevices = [] + pairedRecords = [] + registryByID = [:] + pairedByID = [:] + pairedRecordsByID = [:] + presenceMap = PresenceMap() + presenceGeneration &+= 1 + presenceTask?.cancel() + presenceTask = nil + computers = [] + lastRefreshFailed = false + for (_, continuation) in listeners { + continuation.yield(computers) + } + startPresenceIfNeeded() + return scopeGeneration + } + + private func isCurrentScope(_ scope: HiveAccountScope, generation: Int) async -> Bool { + guard generation == scopeGeneration, loadedScope == scope else { return false } + let latest = await scopeProvider() + guard latest == scope else { + _ = activateScope(latest) + return false + } + return true + } + // MARK: - Pairing /// Pair a computer from its registry row: persist its best instance's /// routes into the local paired store. public func pair(deviceID: String) async -> HivePairOutcome { + let scope = await scopeProvider() + if loadedScope != scope { + await refresh() + guard loadedScope == scope else { return .noRoutes } + } // Pairing with the computer you are sitting at is always a mistake // (dev builds advertise loopback routes, so it would even "work"). guard deviceID != ownDeviceID else { return .loopbackRejected } @@ -162,7 +222,8 @@ public final class HiveComputerDirectory { macDeviceID: deviceID, displayName: computer.displayName, routes: best.routes, - instanceTag: best.instanceTag + instanceTag: best.instanceTag, + scope: scope ) } @@ -182,6 +243,8 @@ public final class HiveComputerDirectory { return .codeNotFound } await refresh() + let scope = await scopeProvider() + guard loadedScope == scope else { return .codeNotFound } let claimTime = now() let matches = registryDevices.flatMap { device in device.instances @@ -196,13 +259,20 @@ public final class HiveComputerDirectory { macDeviceID: match.device.deviceId, displayName: match.device.displayName, routes: match.instance.routes, - instanceTag: match.instance.tag + instanceTag: match.instance.tag, + scope: scope ) } /// Pair from a pasted pairing link (the QR payload another Mac shows). public func pair(link rawLink: String) async -> HivePairOutcome { let scope = await scopeProvider() + if loadedScope == nil { + _ = activateScope(scope) + } else if loadedScope != scope { + await refresh() + return .accountMismatch + } switch linkDecoder.decode(rawLink, currentStackUserID: scope.stackUserID) { case .invalidLink: return .invalidLink @@ -230,7 +300,8 @@ public final class HiveComputerDirectory { macDeviceID: macDeviceID, displayName: ticket.macDisplayName ?? Self.endpointLabel(for: ticket.routes), routes: ticket.routes, - instanceTag: nil + instanceTag: nil, + scope: scope ) } } @@ -256,6 +327,7 @@ public final class HiveComputerDirectory { @discardableResult public func unpair(deviceID: String) async -> Bool { let scope = await scopeProvider() + guard loadedScope == scope else { return false } do { try await pairedStore.remove( macDeviceID: deviceID, @@ -274,10 +346,10 @@ public final class HiveComputerDirectory { macDeviceID: String, displayName: String?, routes: [CmxAttachRoute], - instanceTag: String? + instanceTag: String?, + scope: HiveAccountScope ) async -> HivePairOutcome { guard !routes.isEmpty else { return .noRoutes } - let scope = await scopeProvider() do { try await pairedStore.upsert( macDeviceID: macDeviceID, @@ -301,25 +373,32 @@ public final class HiveComputerDirectory { private func startPresenceIfNeeded() { guard presenceTask == nil, let presence else { return } + presenceGeneration &+= 1 + let generation = presenceGeneration presenceTask = Task { [weak self] in - await self?.runPresenceLoop(presence: presence) + await self?.runPresenceLoop(presence: presence, generation: generation) } } - private func runPresenceLoop(presence: any PresenceSubscribing) async { + private func runPresenceLoop( + presence: any PresenceSubscribing, + generation: Int + ) async { var consecutiveFailures = 0 - while !Task.isCancelled { + while !Task.isCancelled, generation == presenceGeneration { do { let stream = try await presence.subscribe() for try await update in stream { + guard generation == presenceGeneration else { return } consecutiveFailures = 0 presenceMap.apply(update) - rebuild() + rebuild(affectedDeviceIDs: affectedDeviceIDs(for: update)) } } catch { consecutiveFailures += 1 } if Task.isCancelled { return } + invalidatePresence() // The presence stream ended (server deadline) or failed; the // injected delay bounds the resubscribe backoff and is cancelled // with this task. @@ -329,118 +408,49 @@ public final class HiveComputerDirectory { // MARK: - Merge - private func rebuild() { - computers = Self.mergedComputers( - registry: registryDevices, - paired: pairedRecords, - presence: presenceMap, - ownDeviceID: ownDeviceID - ) - for (_, continuation) in listeners { - continuation.yield(computers) - } + private func invalidatePresence() { + presenceMap = PresenceMap() + rebuild() } - /// Pure merge of the three sources into sorted rows. Exposed for tests. - public static func mergedComputers( - registry: [RegistryDevice], - paired: [MobilePairedMac], - presence: PresenceMap, - ownDeviceID: String - ) -> [HiveComputer] { - let pairedByID = Dictionary(uniqueKeysWithValues: paired.map { ($0.macDeviceID, $0) }) - var rows: [HiveComputer] = registry.map { device in - let record = pairedByID[device.deviceId] - return HiveComputer( - deviceID: device.deviceId, - displayName: record?.customName?.nonEmpty - ?? device.displayName?.nonEmpty - ?? record?.displayName?.nonEmpty - ?? String(device.deviceId.prefix(8)), - platform: device.platform, - isThisComputer: device.deviceId == ownDeviceID, - isPaired: record != nil, - presence: Self.presenceState( - for: device.deviceId, - presence: presence, - fallbackLastSeen: device.lastSeenAt - ), - buildLabel: presence.deviceSummary(deviceId: device.deviceId)?.buildLabel, - instances: device.instances.map { instance in - HiveComputerInstance( - tag: instance.tag, - routes: instance.routes, - lastSeenAt: presence.instanceSummary( - deviceId: device.deviceId, - tag: instance.tag - ).map { max($0.lastSeenAt, instance.lastSeenAt) } ?? instance.lastSeenAt, - isOnline: presence.instanceSummary( - deviceId: device.deviceId, - tag: instance.tag - )?.online ?? false - ) + private func rebuild(affectedDeviceIDs: Set? = nil) { + if let affectedDeviceIDs { + for deviceID in affectedDeviceIDs { + if let oldIndex = computers.firstIndex(where: { $0.deviceID == deviceID }) { + computers.remove(at: oldIndex) } + guard let row = rowBuilder.makeRow( + registry: registryByID[deviceID], + paired: pairedByID[deviceID], + pairedRecords: pairedRecordsByID[deviceID] ?? [], + presence: presenceMap + ) else { continue } + let insertionIndex = computers.firstIndex { + rowBuilder.comesBefore(row, $0) + } ?? computers.endIndex + computers.insert(row, at: insertionIndex) + } + } else { + computers = rowBuilder.makeRows( + registry: registryDevices, + paired: pairedRecords, + presence: presenceMap ) } - let registryIDs = Set(registry.map(\.deviceId)) - for record in paired where !registryIDs.contains(record.macDeviceID) { - rows.append( - HiveComputer( - deviceID: record.macDeviceID, - displayName: record.resolvedName, - platform: nil, - isThisComputer: record.macDeviceID == ownDeviceID, - isPaired: true, - presence: Self.presenceState( - for: record.macDeviceID, - presence: presence, - fallbackLastSeen: record.lastSeenAt - ), - buildLabel: presence.deviceSummary(deviceId: record.macDeviceID)?.buildLabel, - instances: [ - HiveComputerInstance( - tag: record.instanceTag ?? "default", - routes: record.routes, - lastSeenAt: record.lastSeenAt, - isOnline: false - ) - ] - ) - ) - } - return rows.sorted { lhs, rhs in - if lhs.isThisComputer != rhs.isThisComputer { return lhs.isThisComputer } - if lhs.presence.isOnline != rhs.presence.isOnline { return lhs.presence.isOnline } - let lhsSeen = lhs.presence.lastSeenAt ?? .distantPast - let rhsSeen = rhs.presence.lastSeenAt ?? .distantPast - if lhs.presence.isOnline == rhs.presence.isOnline, lhsSeen != rhsSeen { - return lhsSeen > rhsSeen - } - return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + for (_, continuation) in listeners { + continuation.yield(computers) } } - private static func presenceState( - for deviceID: String, - presence: PresenceMap, - fallbackLastSeen: Date? - ) -> HiveComputerPresence { - guard let summary = presence.deviceSummary(deviceId: deviceID) else { - return .unknown(lastSeenAt: fallbackLastSeen) + private func affectedDeviceIDs(for update: PresenceUpdate) -> Set? { + switch update { + case .snapshot: + return nil + case .online(let instance), .routes(let instance), .offline(let instance, _): + return [instance.deviceId] + case .seen(let deviceID, _, _): + return [deviceID] } - if summary.online { return .online } - let lastSeen = [summary.lastSeenAt, fallbackLastSeen] - .compactMap { $0 } - .max() - return .offline(lastSeenAt: lastSeen) } -} -extension String { - /// The string itself when non-empty after trimming, else `nil`; merge - /// helper for picking the first usable display name. - fileprivate var nonEmpty: String? { - let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerRowBuilder.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerRowBuilder.swift new file mode 100644 index 000000000000..a75368e83374 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerRowBuilder.swift @@ -0,0 +1,166 @@ +import CMUXMobileCore +public import CmuxMobilePairedMac +public import CmuxMobileShell +public import CmuxMobileShellModel +import Foundation + +/// Builds one immutable computer-row snapshot from registry, pairing, and +/// live-presence values. +/// +/// The directory owns lifecycle and caching; this value type owns only the +/// merge policy so incremental presence updates can rebuild affected rows +/// without rescanning unrelated devices. +public nonisolated struct HiveComputerRowBuilder: Sendable { + /// The local device id used to mark the This Mac row. + public let ownDeviceID: String + + /// Creates a row builder for one local device. + public init(ownDeviceID: String) { + self.ownDeviceID = ownDeviceID + } + + /// Builds all rows from the current source snapshots. + public func makeRows( + registry: [RegistryDevice], + paired: [MobilePairedMac], + presence: PresenceMap + ) -> [HiveComputer] { + let pairedByID = indexPairedRecords(paired) + let pairedRecordsByID = Dictionary(grouping: paired, by: \.macDeviceID) + let registryByID = Dictionary( + registry.map { ($0.deviceId, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let ids = Set(registryByID.keys).union(pairedByID.keys) + return ids.compactMap { id in + makeRow( + registry: registryByID[id], + paired: pairedByID[id], + pairedRecords: pairedRecordsByID[id] ?? [], + presence: presence + ) + } + .sorted(by: comesBefore) + } + + /// Index paired records without trapping when tagged instances share a + /// physical device id; prefer the active, then freshest, record. + func indexPairedRecords(_ records: [MobilePairedMac]) -> [String: MobilePairedMac] { + records.reduce(into: [String: MobilePairedMac]()) { result, record in + guard let existing = result[record.macDeviceID] else { + result[record.macDeviceID] = record + return + } + let preferRecord = (record.isActive && !existing.isActive) + || (record.isActive == existing.isActive && record.lastSeenAt > existing.lastSeenAt) + if preferRecord { + result[record.macDeviceID] = record + } + } + } + + /// Builds a row when either source knows about the device. + public func makeRow( + registry: RegistryDevice?, + paired: MobilePairedMac?, + pairedRecords: [MobilePairedMac] = [], + presence: PresenceMap + ) -> HiveComputer? { + guard registry != nil || paired != nil else { return nil } + let deviceID = registry?.deviceId ?? paired?.macDeviceID ?? "" + let instances: [HiveComputerInstance] + if let registry { + instances = registry.instances.map { instance in + let live = presence.instanceSummary(deviceId: deviceID, tag: instance.tag) + let liveRoutes = presence.instance(deviceId: deviceID, tag: instance.tag)?.routes ?? [] + let pairedRoutes = pairedRecords.first(where: { $0.instanceTag == instance.tag })?.routes + ?? pairedRecords.first(where: { $0.instanceTag == nil })?.routes + ?? [] + return HiveComputerInstance( + tag: instance.tag, + routes: mergedRoutes( + live: liveRoutes, + registry: instance.routes, + paired: pairedRoutes + ), + lastSeenAt: max(live?.lastSeenAt ?? instance.lastSeenAt, instance.lastSeenAt), + isOnline: live?.online ?? false + ) + } + } else { + instances = [ + HiveComputerInstance( + tag: paired?.instanceTag ?? "default", + routes: paired?.routes ?? [], + lastSeenAt: paired?.lastSeenAt ?? .distantPast, + isOnline: false + ) + ] + } + let fallbackLastSeen = registry?.lastSeenAt ?? paired?.lastSeenAt + return HiveComputer( + deviceID: deviceID, + displayName: paired?.customName?.nonEmpty + ?? registry?.displayName?.nonEmpty + ?? paired?.displayName?.nonEmpty + ?? String(deviceID.prefix(8)), + platform: registry?.platform, + isThisComputer: deviceID == ownDeviceID, + isPaired: paired != nil, + presence: presenceState( + for: deviceID, + presence: presence, + fallbackLastSeen: fallbackLastSeen + ), + buildLabel: presence.deviceSummary(deviceId: deviceID)?.buildLabel, + instances: instances + ) + } + + /// Whether the left row should sort before the right row. + public func comesBefore(_ lhs: HiveComputer, _ rhs: HiveComputer) -> Bool { + if lhs.isThisComputer != rhs.isThisComputer { return lhs.isThisComputer } + if lhs.presence.isOnline != rhs.presence.isOnline { + return lhs.presence.isOnline + } + let lhsSeen = lhs.presence.lastSeenAt ?? .distantPast + let rhsSeen = rhs.presence.lastSeenAt ?? .distantPast + if lhs.presence.isOnline == rhs.presence.isOnline, lhsSeen != rhsSeen { + return lhsSeen > rhsSeen + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + + private func presenceState( + for deviceID: String, + presence: PresenceMap, + fallbackLastSeen: Date? + ) -> HiveComputerPresence { + guard let summary = presence.deviceSummary(deviceId: deviceID) else { + return .unknown(lastSeenAt: fallbackLastSeen) + } + if summary.online { return .online } + let lastSeen = [summary.lastSeenAt, fallbackLastSeen].compactMap { $0 }.max() + return .offline(lastSeenAt: lastSeen) + } + + private func mergedRoutes( + live: [CmxAttachRoute], + registry: [CmxAttachRoute], + paired: [CmxAttachRoute] + ) -> [CmxAttachRoute] { + var result: [CmxAttachRoute] = [] + for route in live + registry + paired + where !result.contains(where: { $0.id == route.id }) { + result.append(route) + } + return result.sorted { $0.priority < $1.priority } + } +} + +private extension String { + var nonEmpty: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift index 6fdfcd36d48e..3d717105ff4a 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift @@ -15,10 +15,18 @@ public struct HiveReconnectBackoff: Sendable { } /// Await the backoff for the given consecutive-failure attempt count. + @concurrent public func delay(attempt: Int) async { let seconds = min(maximumSeconds, pow(2.0, Double(min(max(attempt - 1, 0), 6)))) - // Bounded, cancellable delay — the intended behavior, per the - // Clock.sleep carve-out. - try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + // This is a bounded retry delay, not a state-settling sleep. The + // continuous clock observes task cancellation without blocking a + // thread or inheriting the caller's actor. + do { + try await ContinuousClock().sleep(for: .seconds(seconds)) + } catch is CancellationError { + return + } catch { + return + } } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index 34debde65547..4727a10be601 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -1,5 +1,6 @@ public import CMUXMobileCore public import CmuxMobileRPC +public import CmuxMobileShellModel import Foundation public import Observation @@ -43,6 +44,8 @@ public final class HiveRemoteMacSession { @ObservationIgnored private let runtime: any MobileSyncRuntime @ObservationIgnored private let routes: [CmxAttachRoute] @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void + @ObservationIgnored private let workspaceDecoder: HiveRemoteWorkspaceDecoder + @ObservationIgnored private let stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust /// The connected RPC client terminal sessions share, `nil` until the /// first successful connect. @ObservationIgnored public private(set) var client: MobileCoreRPCClient? @@ -61,18 +64,23 @@ public final class HiveRemoteMacSession { /// - retryDelay: Awaited between reconnect attempts with the /// consecutive-failure count; production passes a bounded backoff /// sleep, tests a recorder that returns immediately. + /// - stackAuthChannelTrust: The route provenance trusted to carry the + /// account token. Manual links default to loopback-only. public init( runtime: any MobileSyncRuntime, macDeviceID: String, displayName: String, routes: [CmxAttachRoute], - retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void + retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void, + stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust = .loopbackOnly ) { self.runtime = runtime self.macDeviceID = macDeviceID self.displayName = displayName self.routes = routes self.retryDelay = retryDelay + self.workspaceDecoder = HiveRemoteWorkspaceDecoder() + self.stackAuthChannelTrust = stackAuthChannelTrust } /// Start (or restart) the session: dial routes, fetch the workspace list, @@ -89,9 +97,13 @@ public final class HiveRemoteMacSession { /// Tear down the session (window closed). public func disconnect() async { - connectTask?.cancel() + let pendingConnect = connectTask + pendingConnect?.cancel() + await pendingConnect?.value connectTask = nil - eventTask?.cancel() + let pendingEvents = eventTask + pendingEvents?.cancel() + await pendingEvents?.value eventTask = nil if let client { await client.disconnect() @@ -104,7 +116,7 @@ public final class HiveRemoteMacSession { public func refreshWorkspaces() async { guard let client else { return } do { - setWorkspaces(try await Self.fetchWorkspaces(client: client)) + setWorkspaces(try await fetchWorkspaces(client: client)) } catch { // Keep the stale list; the event loop's stream death drives the // visible reconnect state. @@ -145,6 +157,7 @@ public final class HiveRemoteMacSession { .filter { supported.contains($0.kind) } .sorted { $0.priority < $1.priority } guard !candidates.isEmpty else { + guard !Task.isCancelled else { return } phase = .failed(message: Self.noRouteMessage) return } @@ -164,10 +177,16 @@ public final class HiveRemoteMacSession { route: route, ticket: ticket, allowsStackAuthFallback: true, - stackAuthChannelTrust: .loopbackAndTailscaleTunnel + stackAuthChannelTrust: stackAuthChannelTrust ) do { - let workspaces = try await Self.fetchWorkspaces(client: candidate) + let workspaces = try await fetchWorkspaces(client: candidate) + // disconnect() can cancel this task while the RPC is + // suspended. Do not resurrect a session after teardown. + guard !Task.isCancelled else { + await candidate.disconnect() + return + } if let previous = client { await previous.disconnect() } client = candidate setWorkspaces(workspaces) @@ -179,9 +198,13 @@ public final class HiveRemoteMacSession { await candidate.disconnect() } } - phase = .failed(message: (lastError as? MobileShellConnectionError)?.localizedDescription - ?? lastError.map(String.init(describing:)) - ?? Self.noRouteMessage) + guard !Task.isCancelled else { return } + // Upstream transport/decoding errors can contain addresses, request + // ids, or other implementation details. Keep the viewer's failure + // state product-facing and localized instead of exposing them. + phase = .failed(message: lastError == nil + ? Self.noRouteMessage + : Self.connectionFailedMessage) } /// A route-carrier ticket for the viewer. It authorizes nothing (no @@ -201,20 +224,13 @@ public final class HiveRemoteMacSession { ) } - private static func fetchWorkspaces(client: MobileCoreRPCClient) async throws -> [HiveRemoteWorkspace] { + private func fetchWorkspaces(client: MobileCoreRPCClient) async throws -> [HiveRemoteWorkspace] { let request = try MobileCoreRPCClient.requestData(method: "mobile.workspace.list") let data = try await client.sendRequest(request) - let response = try MobileSyncWorkspaceListResponse.decode(data) - return response.workspaces.map { workspace in - HiveRemoteWorkspace( - id: workspace.id, - title: workspace.title, - isSelected: workspace.isSelected, - terminals: workspace.terminals.map { - HiveRemoteWorkspace.Terminal(id: $0.id, title: $0.title, isFocused: $0.isFocused) - } - ) - } + let decoder = workspaceDecoder + return try await Task.detached(priority: .userInitiated) { + try decoder.decode(data) + }.value } // MARK: - Events @@ -239,8 +255,10 @@ public final class HiveRemoteMacSession { ) _ = try await client.sendRequest(subscribe) consecutiveFailures = 0 + guard !Task.isCancelled else { return } phase = .connected await refreshWorkspaces() + guard !Task.isCancelled else { return } } catch is CancellationError { return } catch { @@ -267,4 +285,11 @@ public final class HiveRemoteMacSession { defaultValue: "This computer hasn't advertised a reachable address. Make sure Tailscale is running on both Macs." ) } + + private static var connectionFailedMessage: String { + String( + localized: "hive.viewer.error.connection", + defaultValue: "The other Mac couldn't be reached. Check that it is online and paired, then try again." + ) + } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridDecoder.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridDecoder.swift new file mode 100644 index 000000000000..5228bb1e9a87 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridDecoder.swift @@ -0,0 +1,21 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation + +/// Decodes the two wire shapes used by remote render-grid events and replay. +struct HiveRemoteRenderGridDecoder: Sendable { + /// Decode a wrapped or bare render-grid event. + func decodeFrame(_ payload: Data) -> MobileTerminalRenderGridFrame? { + let decoder = JSONDecoder() + if let event = try? decoder.decode(MobileTerminalRenderGridEvent.self, from: payload), + let frame = event.frame { + return frame + } + return try? decoder.decode(MobileTerminalRenderGridFrame.self, from: payload) + } + + /// Decode one terminal replay response. + func decodeReplay(_ payload: Data) throws -> MobileTerminalReplayResponse { + try JSONDecoder().decode(MobileTerminalReplayResponse.self, from: payload) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift index c782acee8155..d22ac70e5a20 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -48,7 +48,15 @@ public final class HiveRemoteTerminalSession { @ObservationIgnored private let client: MobileCoreRPCClient @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void + @ObservationIgnored private let renderGridDecoder: HiveRemoteRenderGridDecoder @ObservationIgnored private var attachTask: Task? + @ObservationIgnored private var replayTask: Task? + @ObservationIgnored private var frameApplyTask: Task? + @ObservationIgnored private var pendingFrames: [MobileTerminalRenderGridFrame] = [] + @ObservationIgnored private var frameQueueNeedsReplay = false + @ObservationIgnored private var inputTask: Task? + @ObservationIgnored private var pendingInput: [String] = [] + @ObservationIgnored private var inputGeneration = 0 /// Creates a terminal view session over an already-connected client. /// @@ -69,6 +77,7 @@ public final class HiveRemoteTerminalSession { self.workspaceID = workspaceID self.terminalID = terminalID self.retryDelay = retryDelay + self.renderGridDecoder = HiveRemoteRenderGridDecoder() } /// Start the attach/replay/event loop. Idempotent while running. @@ -84,6 +93,16 @@ public final class HiveRemoteTerminalSession { public func detach() { attachTask?.cancel() attachTask = nil + replayTask?.cancel() + replayTask = nil + frameApplyTask?.cancel() + frameApplyTask = nil + pendingFrames.removeAll(keepingCapacity: true) + frameQueueNeedsReplay = false + inputGeneration &+= 1 + inputTask?.cancel() + inputTask = nil + pendingInput.removeAll(keepingCapacity: true) phase = .idle } @@ -91,8 +110,18 @@ public final class HiveRemoteTerminalSession { /// first applies its real size (a replay delivered to a zero-sized manual /// surface renders nothing until the next full frame). public func refreshReplay() { - Task { [weak self] in - try? await self?.requestReplay() + replayTask?.cancel() + replayTask = Task { [weak self] in + guard let self else { return } + do { + try await self.requestReplay() + } catch is CancellationError { + return + } catch { + // The attach loop owns recovery; a manual refresh is best + // effort and must not create a second recovery loop. + } + self.replayTask = nil } } @@ -120,24 +149,57 @@ public final class HiveRemoteTerminalSession { } private func sendInput(_ text: String) { - let workspaceID = workspaceID - let terminalID = terminalID - let client = client - Task { - guard let request = try? MobileCoreRPCClient.requestData( - method: "mobile.terminal.input", - params: [ - "workspace_id": workspaceID, - "surface_id": terminalID, - "text": text, - ] - ) else { return } - // Fire-and-forget: a failed keystroke surfaces through the event - // stream dying (reattach), not through per-key errors. - _ = try? await client.sendRequest(request) + guard phase == .live || phase == .reattaching else { return } + pendingInput.append(text) + startInputWorkerIfNeeded() + } + + private func startInputWorkerIfNeeded() { + guard inputTask == nil, phase == .live else { return } + let generation = inputGeneration + inputTask = Task { [weak self] in + guard let self else { return } + defer { + if self.inputGeneration == generation { + self.inputTask = nil + } + } + while !Task.isCancelled { + guard let text = self.dequeueInput(for: generation) else { break } + do { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.terminal.input", + params: [ + "workspace_id": self.workspaceID, + "surface_id": self.terminalID, + "text": text, + ] + ) + try Task.checkCancellation() + _ = try await self.client.sendRequest(request) + } catch is CancellationError { + return + } catch { + // Keep the lifecycle truthful when an input request fails; + // the attach loop will re-establish the stream. + pendingInput.insert(text, at: 0) + if self.inputGeneration == generation, self.phase == .live { + self.phase = .reattaching + self.attachTask?.cancel() + self.attachTask = nil + self.attach() + } + return + } + } } } + private func dequeueInput(for generation: Int) -> String? { + guard generation == inputGeneration, phase == .live, !pendingInput.isEmpty else { return nil } + return pendingInput.removeFirst() + } + // MARK: - Attach loop private func runAttachLoop() async { @@ -153,8 +215,10 @@ public final class HiveRemoteTerminalSession { ) _ = try await client.sendRequest(subscribe) try await requestReplay() + guard !Task.isCancelled else { return } consecutiveFailures = 0 phase = .live + startInputWorkerIfNeeded() } catch is CancellationError { return } catch { @@ -166,9 +230,10 @@ public final class HiveRemoteTerminalSession { } for await envelope in stream { guard let payload = envelope.payloadJSON, - let frame = Self.decodeFrame(payload), + let frame = await decodeFrameOffMain(payload), frame.surfaceID == terminalID else { continue } - applyFrame(frame) + guard !Task.isCancelled else { return } + enqueueFrame(frame) } // Stream finished: transport died. Re-subscribe + re-replay. if Task.isCancelled { return } @@ -187,14 +252,57 @@ public final class HiveRemoteTerminalSession { ] ) let data = try await client.sendRequest(request) - let response = try MobileTerminalReplayResponse.decode(data) - if let frame = response.renderGrid { - applyFrame(frame) + let decoder = renderGridDecoder + let response = try await Task.detached(priority: .userInitiated) { + try decoder.decodeReplay(data) + }.value + try Task.checkCancellation() + guard let frame = response.renderGrid else { + throw HiveRemoteTerminalSessionError.missingFrame + } + guard frame.full, frame.surfaceID == terminalID else { + throw frame.surfaceID == terminalID + ? HiveRemoteTerminalSessionError.incompleteFrame + : HiveRemoteTerminalSessionError.mismatchedSurface + } + pendingFrames.removeAll(keepingCapacity: true) + applyFrame(frame, allowingFullReset: true) + } + + private func enqueueFrame(_ frame: MobileTerminalRenderGridFrame) { + if pendingFrames.count >= 256 { + pendingFrames.removeAll(keepingCapacity: true) + frameQueueNeedsReplay = true + } + pendingFrames.append(frame) + guard frameApplyTask == nil else { return } + frameApplyTask = Task { [weak self] in + await Task.yield() + guard let self else { return } + let frames = self.pendingFrames + self.pendingFrames.removeAll(keepingCapacity: true) + for frame in frames { + self.applyFrame(frame) + } + let needsReplay = self.frameQueueNeedsReplay + self.frameQueueNeedsReplay = false + let remaining = needsReplay ? [] : self.pendingFrames + self.pendingFrames.removeAll(keepingCapacity: true) + self.frameApplyTask = nil + if needsReplay { + self.refreshReplay() + } + for frame in remaining { + self.enqueueFrame(frame) + } } } - private func applyFrame(_ frame: MobileTerminalRenderGridFrame) { - grid.apply(frame) + private func applyFrame( + _ frame: MobileTerminalRenderGridFrame, + allowingFullReset: Bool = false + ) { + guard grid.apply(frame, allowingFullReset: allowingFullReset) else { return } let replay = MobileTerminalRenderGridReplay(frame) let bytes = frame.full ? replay.replacementBytes() : replay.patchBytes() if frame.full { lastFullFrameBytes = bytes } @@ -203,10 +311,10 @@ public final class HiveRemoteTerminalSession { /// Decode one `terminal.render_grid` event payload: either the wrapped /// `{"render_grid": …}` form or the bare frame. - static func decodeFrame(_ payload: Data) -> MobileTerminalRenderGridFrame? { - if let event = try? MobileTerminalRenderGridEvent.decode(payload), let frame = event.frame { - return frame - } - return try? JSONDecoder().decode(MobileTerminalRenderGridFrame.self, from: payload) + private func decodeFrameOffMain(_ payload: Data) async -> MobileTerminalRenderGridFrame? { + let decoder = renderGridDecoder + return await Task.detached(priority: .userInitiated) { + decoder.decodeFrame(payload) + }.value } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift new file mode 100644 index 000000000000..7951958d4b19 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift @@ -0,0 +1,6 @@ +/// Errors that keep an incomplete or misrouted replay from becoming live. +enum HiveRemoteTerminalSessionError: Error { + case missingFrame + case incompleteFrame + case mismatchedSurface +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift index 4b69131ac107..cf812c15b3db 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift @@ -1,9 +1,9 @@ /// One workspace on a remote Mac, as reported by `workspace.list`. /// /// Value snapshot for the viewer's workspace list (rows never observe stores). -public struct HiveRemoteWorkspace: Equatable, Sendable, Identifiable { +public nonisolated struct HiveRemoteWorkspace: Equatable, Sendable, Identifiable { /// One terminal within the workspace. - public struct Terminal: Equatable, Sendable, Identifiable { + public nonisolated struct Terminal: Equatable, Sendable, Identifiable { /// Stable terminal (surface) identifier on the host. public let id: String /// User-facing terminal title. diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspaceDecoder.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspaceDecoder.swift new file mode 100644 index 000000000000..dd23bd08646c --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspaceDecoder.swift @@ -0,0 +1,25 @@ +import CmuxMobileRPC +import Foundation + +/// Decodes a remote workspace-list payload and projects it into Hive's +/// immutable workspace snapshot. +struct HiveRemoteWorkspaceDecoder: Sendable { + /// Decode one mobile workspace-list response. + func decode(_ data: Data) throws -> [HiveRemoteWorkspace] { + let response = try MobileSyncWorkspaceListResponse.decode(data) + return response.workspaces.map { workspace in + HiveRemoteWorkspace( + id: workspace.id, + title: workspace.title, + isSelected: workspace.isSelected, + terminals: workspace.terminals.map { + HiveRemoteWorkspace.Terminal( + id: $0.id, + title: $0.title, + isFocused: $0.isFocused + ) + } + ) + } + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift index 108b3317aa07..8c85784b4313 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift @@ -13,7 +13,7 @@ import Foundation /// /// Pure value type: the reduction is synchronous and deterministic, so frame /// application is unit-testable without any transport or view. -public struct HiveTerminalGridModel: Equatable, Sendable { +public nonisolated struct HiveTerminalGridModel: Equatable, Sendable { /// One styled run of text within a row. public struct Span: Equatable, Sendable { /// Zero-based start column. @@ -71,26 +71,38 @@ public struct HiveTerminalGridModel: Equatable, Sendable { /// A full frame replaces the whole grid; a delta clears its cleared rows /// plus every row it repaints, then applies the spans. A delta arriving /// before any full frame is ignored (the viewer requests a replay on - /// attach, so a full frame always precedes meaningful deltas). - public mutating func apply(_ frame: MobileTerminalRenderGridFrame) { + /// attach, so a full frame always precedes meaningful deltas). Full frames + /// older than the current sequence are rejected unless the caller marks a + /// new session epoch with allowingFullReset. + /// + /// - Parameter allowingFullReset: Accept a replay from a new host/session + /// epoch even when its producer sequence is lower. + @discardableResult + public mutating func apply( + _ frame: MobileTerminalRenderGridFrame, + allowingFullReset: Bool = false + ) -> Bool { let stylesByID = Dictionary( frame.styles.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) if frame.full { + guard allowingFullReset || !hasContent || frame.stateSeq >= stateSeq else { + return false + } columns = max(frame.columns, 1) rows = max(frame.rows, 1) rowSpans = Array(repeating: [], count: rows) hasContent = true } else { - guard hasContent else { return } + guard hasContent, frame.stateSeq > stateSeq else { return false } if frame.columns > 0, frame.rows > 0, frame.columns != columns || frame.rows != rows { resize(columns: frame.columns, rows: frame.rows) } for row in Set(frame.clearedRows).union(frame.rowSpans.map(\.row)) { guard rowSpans.indices.contains(row) else { continue } - rowSpans[row] = [] + rowSpans[row].removeAll(keepingCapacity: true) } } for span in frame.rowSpans { @@ -114,6 +126,7 @@ public struct HiveTerminalGridModel: Equatable, Sendable { if let background = frame.terminalBackground { terminalBackground = background } if let cursorColor = frame.terminalCursorColor { terminalCursorColor = cursorColor } stateSeq = frame.stateSeq + return true } /// The plain text of one row (spans joined with gap-filling spaces), for diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift index 9c986757f4eb..72be277ad03a 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -181,7 +181,7 @@ private func makeDirectory( #expect(await directory.pair(deviceID: "self-device") == .loopbackRejected) #expect(await directory.pair(deviceID: "routeless-mac") == .noRoutes) #expect(await directory.pair(deviceID: "phone") == .noRoutes) - let persisted = try await store.loadAll(stackUserID: nil, teamID: nil) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") #expect(persisted.isEmpty) } @@ -204,7 +204,7 @@ private func makeDirectory( _ = await directory.pair(deviceID: "registry-mac") #expect(await directory.unpair(deviceID: "registry-mac")) - let persisted = try await store.loadAll(stackUserID: nil, teamID: nil) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") #expect(persisted.isEmpty) // The registry row stays visible, just unpaired. let row = try #require(directory.computers.first { $0.deviceID == "registry-mac" }) @@ -226,14 +226,21 @@ private func makeDirectory( await directory.refresh() #expect(directory.computers.count == 1) - // Swap in a failing registry by building a new directory is not - // possible mid-flight, so assert the failure path on a fresh - // directory that has never seen the registry: rows stay local-only - // and the failure flag is set. - let failing = makeDirectory(registry: .transientFailure, store: store) - await failing.refresh() - #expect(failing.lastRefreshFailed) - #expect(failing.computers.isEmpty) + let scripted = SequencedRegistry([.ok([device]), .transientFailure]) + let warm = HiveComputerDirectory( + registry: scripted, + pairedStore: store, + presence: nil, + ownDeviceID: "self-device", + scopeProvider: { HiveAccountScope(stackUserID: "user-1", teamID: "team-1") }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + now: { Date(timeIntervalSince1970: 1_000) }, + presenceRetryDelay: { _ in } + ) + await warm.refresh() + await warm.refresh() + #expect(warm.lastRefreshFailed) + #expect(warm.computers.count == 1) } @MainActor @@ -287,11 +294,10 @@ private func makeDirectory( lastSeenAt: Date(timeIntervalSince1970: 800), instances: [] ) - let rows = HiveComputerDirectory.mergedComputers( + let rows = HiveComputerRowBuilder(ownDeviceID: "self-device").makeRows( registry: [registryDevice, offlineDevice], paired: [], - presence: map, - ownDeviceID: "self-device" + presence: map ) let online = try #require(rows.first { $0.deviceID == "registry-mac" }) #expect(online.presence == .online) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift index 2b616996574c..ba36560566a1 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift @@ -85,7 +85,8 @@ private func jsonObject(_ string: String) -> [String: Any] { macDeviceID: "mac-b", displayName: "Studio", routes: [try tailscaleRoute()], - retryDelay: { _ in } + retryDelay: { _ in }, + stackAuthChannelTrust: .loopbackAndTailscaleTunnel ) session.connect() await transport.waitForMethod("mobile.events.subscribe") @@ -134,7 +135,8 @@ private func jsonObject(_ string: String) -> [String: Any] { macDeviceID: "mac-b", displayName: "Studio", routes: [try tailscaleRoute()], - retryDelay: { _ in } + retryDelay: { _ in }, + stackAuthChannelTrust: .loopbackAndTailscaleTunnel ) macSession.connect() try await waitUntil { macSession.phase == .connected } @@ -174,12 +176,17 @@ private func jsonObject(_ string: String) -> [String: Any] { #expect(terminal.grid.plainRow(2) == "") // Typing reaches the host's PTY via terminal.input. - terminal.send(text: "ls\r") - await transport.waitForMethod("mobile.terminal.input") + terminal.send(text: "a") + terminal.send(text: "b") + terminal.send(text: "c") + await transport.waitForMethod("mobile.terminal.input", count: 3) let inputs = await transport.sentInputTexts - #expect(inputs == ["ls\r"]) + #expect(inputs == ["a", "b", "c"]) terminal.detach() + terminal.send(text: "late") + await Task.yield() + #expect(await transport.sentInputTexts == ["a", "b", "c"]) await macSession.disconnect() } @@ -210,7 +217,8 @@ private func jsonObject(_ string: String) -> [String: Any] { macDeviceID: "mac-b", displayName: "Studio", routes: [try tailscaleRoute()], - retryDelay: { _ in } + retryDelay: { _ in }, + stackAuthChannelTrust: .loopbackAndTailscaleTunnel ) macSession.connect() try await waitUntil { macSession.phase == .connected } diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift index 4cc36015e85c..93e142d5cc0f 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift @@ -62,7 +62,7 @@ import Testing #expect(grid.plainRow(2) == "") #expect(grid.cursor?.row == 1) #expect(grid.cursor?.column == 7) - #expect(grid.rowSpans[1].first?.style.bold == true) + #expect(grid.rowSpans.dropFirst().first?.first?.style.bold == true) #expect(grid.terminalBackground == "#000000") #expect(grid.stateSeq == 1) } @@ -105,6 +105,37 @@ import Testing #expect(grid.rows == 0) } + @Test func staleDeltaAfterNewerFrameIsIgnored() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame(stateSeq: 10, rowSpans: [ + .init(row: 0, column: 0, styleID: 0, text: "new") + ])) + grid.apply(try deltaFrame( + stateSeq: 9, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "old")] + )) + + #expect(grid.plainRow(0) == "new") + #expect(grid.stateSeq == 10) + } + + @Test func staleFullFrameNeedsAnExplicitSessionReset() throws { + var grid = HiveTerminalGridModel() + grid.apply(try fullFrame(stateSeq: 10, rowSpans: [ + .init(row: 0, column: 0, styleID: 0, text: "current") + ])) + let stale = try fullFrame(stateSeq: 9, rowSpans: [ + .init(row: 0, column: 0, styleID: 0, text: "stale") + ]) + + let rejected = grid.apply(stale) + #expect(!rejected) + #expect(grid.plainRow(0) == "current") + let reset = grid.apply(stale, allowingFullReset: true) + #expect(reset) + #expect(grid.plainRow(0) == "stale") + } + @Test func deltaWithNewGeometryResizesGrid() throws { var grid = HiveTerminalGridModel() grid.apply(try fullFrame(columns: 10, rows: 4)) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift index 5f041dfbd489..fd5e9cd73c8c 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift @@ -76,6 +76,7 @@ actor ScriptedHostTransport: CmxByteTransport { /// Simulate the connection dying host-side (EOF to the client's reader). func killConnection() { + isClosed = true let waiters = receiveWaiters receiveWaiters.removeAll() for waiter in waiters { diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/SequencedRegistry.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/SequencedRegistry.swift new file mode 100644 index 000000000000..eca89b3c2643 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/SequencedRegistry.swift @@ -0,0 +1,20 @@ +import CMUXMobileCore +import CmuxMobileShell + +/// Registry fake that returns scripted outcomes in order. +actor SequencedRegistry: DeviceRegistryRefreshing { + private var outcomes: [DeviceRegistryListOutcome] + + init(_ outcomes: [DeviceRegistryListOutcome]) { + self.outcomes = outcomes + } + + func freshRoutes(forMacDeviceID macDeviceID: String, instanceTag: String?) async -> [CmxAttachRoute]? { + nil + } + + func listDevices() async -> DeviceRegistryListOutcome { + guard outcomes.count > 1 else { return outcomes.first ?? .transientFailure } + return outcomes.removeFirst() + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift index 96690fb69ce4..89a56c31910a 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift @@ -20,7 +20,7 @@ public struct HiveRemoteTerminalPane: View { sendSpecial: { [weak terminal] in terminal?.send(specialKey: $0, modifiers: $1) }, sendControl: { [weak terminal] in terminal?.send(controlCharacter: $0) } ), - isFocused: true + isFocused: terminal.phase == .live ) statusOverlay } diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetrics.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetrics.swift new file mode 100644 index 000000000000..c76fc668e95c --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetrics.swift @@ -0,0 +1,35 @@ +import AppKit +import SwiftUI + +/// Cell geometry for fitting a remote terminal grid into a Canvas. +struct HiveTerminalGridMetrics: Equatable { + let cellWidth: CGFloat + let lineHeight: CGFloat + let fontSize: CGFloat + + init(columns: Int, rows: Int, available: CGSize) { + let columns = max(columns, 1) + let rows = max(rows, 1) + let referenceSize: CGFloat = 13 + let referenceFont = NSFont.monospacedSystemFont(ofSize: referenceSize, weight: .regular) + let referenceAdvance = ("0" as NSString).size(withAttributes: [.font: referenceFont]).width + let referenceLineHeight = NSLayoutManager().defaultLineHeight(for: referenceFont) + let widthLimited = available.width / (CGFloat(columns) * referenceAdvance / referenceSize) + let heightLimited = available.height / (CGFloat(rows) * referenceLineHeight / referenceSize) + let size = max(min(widthLimited, heightLimited, 20), 4) + fontSize = size + cellWidth = referenceAdvance * size / referenceSize + lineHeight = referenceLineHeight * size / referenceSize + } + + func origin(row: Int, column: Int) -> CGPoint { + CGPoint(x: CGFloat(column) * cellWidth, y: CGFloat(row) * lineHeight) + } + + func font(bold: Bool, italic: Bool) -> Font { + var font = Font.system(size: fontSize, design: .monospaced) + if bold { font = font.bold() } + if italic { font = font.italic() } + return font + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetricsCache.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetricsCache.swift new file mode 100644 index 000000000000..c7052391b259 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetricsCache.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Cached Canvas metrics keyed by the remote grid dimensions and available size. +struct HiveTerminalGridMetricsCache { + let columns: Int + let rows: Int + let available: CGSize + let metrics: HiveTerminalGridMetrics + + func matches(columns: Int, rows: Int, available: CGSize) -> Bool { + self.columns == columns && self.rows == rows && self.available == available + } +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift index 4bf0cd70f0b4..2cb1056978f5 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift @@ -1,5 +1,6 @@ public import CmuxHive import CMUXMobileCore +import AppKit public import SwiftUI /// Draws one ``HiveTerminalGridModel`` snapshot as a fixed-cell text grid. @@ -11,6 +12,7 @@ public import SwiftUI /// remote grid's columns × rows. public struct HiveTerminalGridView: View { private let grid: HiveTerminalGridModel + @State private var cachedMetrics: HiveTerminalGridMetricsCache? /// Creates a grid view over one immutable grid snapshot. public init(grid: HiveTerminalGridModel) { @@ -18,17 +20,35 @@ public struct HiveTerminalGridView: View { } public var body: some View { - GeometryReader { proxy in - let metrics = HiveTerminalGridMetrics( + Canvas { context, size in + let metrics: HiveTerminalGridMetrics + if let cachedMetrics, + cachedMetrics.matches(columns: grid.columns, rows: grid.rows, available: size) { + metrics = cachedMetrics.metrics + } else { + metrics = HiveTerminalGridMetrics( + columns: grid.columns, + rows: grid.rows, + available: size + ) + } + draw(in: &context, metrics: metrics) + } + .background(HiveTerminalColor.parse(grid.terminalBackground) ?? HiveTerminalColor.fallbackBackground) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size in + cachedMetrics = HiveTerminalGridMetricsCache( columns: grid.columns, rows: grid.rows, - available: proxy.size + available: size, + metrics: HiveTerminalGridMetrics( + columns: grid.columns, + rows: grid.rows, + available: size + ) ) - Canvas { context, _ in - draw(in: &context, metrics: metrics) - } } - .background(HiveTerminalColor.parse(grid.terminalBackground) ?? HiveTerminalColor.fallbackBackground) + .onChange(of: grid.columns) { _, _ in cachedMetrics = nil } + .onChange(of: grid.rows) { _, _ in cachedMetrics = nil } } private func draw(in context: inout GraphicsContext, metrics: HiveTerminalGridMetrics) { @@ -69,7 +89,6 @@ public struct HiveTerminalGridView: View { background = foreground foreground = swappedForeground } - if style.invisible { return } let origin = metrics.origin(row: row, column: span.column) if let background { let rect = CGRect( @@ -80,6 +99,7 @@ public struct HiveTerminalGridView: View { ) context.fill(Path(rect), with: .color(background)) } + if style.invisible { return } var attributes = AttributeContainer() attributes.font = metrics.font(bold: style.bold, italic: style.italic) attributes.foregroundColor = style.faint ? foreground.opacity(0.6) : foreground @@ -142,40 +162,5 @@ public struct HiveTerminalGridView: View { guard let rect = cursorRect(metrics: metrics), grid.cursor?.style == .blockHollow else { return } context.stroke(Path(rect), with: .color(cursorColor), lineWidth: 1) } -} - -/// Cell geometry: fits the remote grid's columns × rows into the available -/// size by scaling one monospaced font. -struct HiveTerminalGridMetrics { - let cellWidth: CGFloat - let lineHeight: CGFloat - let fontSize: CGFloat - - init(columns: Int, rows: Int, available: CGSize) { - let columns = max(columns, 1) - let rows = max(rows, 1) - // Measure the reference font once; monospaced advances scale linearly - // with point size, so one measurement fits any target size. - let referenceSize: CGFloat = 13 - let referenceFont = NSFont.monospacedSystemFont(ofSize: referenceSize, weight: .regular) - let referenceAdvance = ("0" as NSString).size(withAttributes: [.font: referenceFont]).width - let referenceLineHeight = NSLayoutManager().defaultLineHeight(for: referenceFont) - let widthLimited = available.width / (CGFloat(columns) * referenceAdvance / referenceSize) - let heightLimited = available.height / (CGFloat(rows) * referenceLineHeight / referenceSize) - let size = max(min(widthLimited, heightLimited, 20), 4) - fontSize = size - cellWidth = referenceAdvance * size / referenceSize - lineHeight = referenceLineHeight * size / referenceSize - } - - func origin(row: Int, column: Int) -> CGPoint { - CGPoint(x: CGFloat(column) * cellWidth, y: CGFloat(row) * lineHeight) - } - func font(bold: Bool, italic: Bool) -> Font { - var font = Font.system(size: fontSize, design: .monospaced) - if bold { font = font.bold() } - if italic { font = font.italic() } - return font - } } diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift index ead725ec672c..ab691e4909e6 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift @@ -48,26 +48,60 @@ public struct HiveTerminalInputView: NSViewRepresentable { public func updateNSView(_ nsView: HiveTerminalKeyCaptureNSView, context: Context) { nsView.actions = actions - if isFocused, nsView.window?.firstResponder !== nsView { - nsView.window?.makeFirstResponder(nsView) - } + nsView.setInputEnabled(isFocused) } } /// The AppKit key-capture view backing ``HiveTerminalInputView``. -public final class HiveTerminalKeyCaptureNSView: NSView { +public final class HiveTerminalKeyCaptureNSView: NSView, @preconcurrency NSTextInputClient { var actions: HiveTerminalInputView.Actions? + private var inputEnabled = false + private var didRequestInitialFocus = false + private var markedText = NSMutableAttributedString() + private var markedSelection = NSRange(location: 0, length: 0) override public var acceptsFirstResponder: Bool { true } + func setInputEnabled(_ enabled: Bool) { + inputEnabled = enabled + if !enabled { + didRequestInitialFocus = false + if window?.firstResponder === self { + window?.makeFirstResponder(nil) + } + unmarkText() + } else { + claimInitialFocusIfNeeded() + } + } + + override public func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + claimInitialFocusIfNeeded() + } + + private func claimInitialFocusIfNeeded() { + guard inputEnabled, !didRequestInitialFocus, let window else { return } + didRequestInitialFocus = true + if window.firstResponder !== self { + window.makeFirstResponder(self) + } + } + override public func mouseDown(with event: NSEvent) { + guard inputEnabled else { return } window?.makeFirstResponder(self) } override public func keyDown(with event: NSEvent) { + guard inputEnabled else { + super.keyDown(with: event) + return + } // Cmd+V pastes into the remote PTY; every other Command chord stays // with the app (the mapping returns nil for Command). - if event.modifierFlags.contains(.command), + let deviceIndependentFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if deviceIndependentFlags == [.command], event.charactersIgnoringModifiers?.lowercased() == "v" { if let pasted = NSPasteboard.general.string(forType: .string), !pasted.isEmpty { actions?.sendText(pasted) @@ -88,8 +122,133 @@ public final class HiveTerminalKeyCaptureNSView: NSView { actions?.sendSpecial(key, modifiers) case .control(let character): actions?.sendControl(character) - case .text(let text): - actions?.sendText(text) + case .text(_) where event.keyCode == 36 || event.keyCode == 76: + actions?.sendText("\u{000D}") + case .text(_) where event.keyCode == 51: + actions?.sendText("\u{007F}") + case .text(_): + // Let AppKit's text system resolve dead keys and IME composition. + // Committed text arrives through insertText(_:replacementRange:). + interpretKeyEvents([event]) + } + } + + // MARK: NSTextInputClient + + /// Deliver committed text, including text finalized by an input method. + public func insertText(_ insertString: Any, replacementRange: NSRange) { + let text: String + switch insertString { + case let attributed as NSAttributedString: + text = attributed.string + case let string as String: + text = string + default: + return + } + unmarkText() + guard inputEnabled, !text.isEmpty else { return } + actions?.sendText(text) + } + + /// Store marked (pre-edit) text until the input method commits it. + public func setMarkedText( + _ markedText: Any, + selectedRange: NSRange, + replacementRange: NSRange + ) { + switch markedText { + case let attributed as NSAttributedString: + self.markedText = NSMutableAttributedString(attributedString: attributed) + case let string as String: + self.markedText = NSMutableAttributedString(string: string) + default: + return + } + self.markedSelection = clampedRange(selectedRange, length: self.markedText.length) + } + + /// Clear the current input-method composition. + public func unmarkText() { + markedText.mutableString.setString("") + markedSelection = NSRange(location: 0, length: 0) + } + + /// Whether an input method currently owns marked text. + public func hasMarkedText() -> Bool { + markedText.length > 0 + } + + /// Return the range occupied by marked text, if any. + public func markedRange() -> NSRange { + hasMarkedText() + ? NSRange(location: 0, length: markedText.length) + : NSRange(location: NSNotFound, length: 0) + } + + /// Return the input method's current selection range. + public func selectedRange() -> NSRange { + hasMarkedText() ? markedSelection : NSRange(location: 0, length: 0) + } + + /// This capture view does not expose custom marked-text attributes. + public func validAttributesForMarkedText() -> [NSAttributedString.Key] { + [] + } + + /// Return marked text for input-method candidate queries. + public func attributedSubstring( + forProposedRange range: NSRange, + actualRange: NSRangePointer? + ) -> NSAttributedString? { + guard hasMarkedText() else { return nil } + let clamped = clampedRange(range, length: markedText.length) + actualRange?.pointee = clamped + return markedText.attributedSubstring(from: clamped) + } + + /// Return a screen rectangle where the input method can place candidates. + public func firstRect( + forCharacterRange range: NSRange, + actualRange: NSRangePointer? + ) -> NSRect { + actualRange?.pointee = range + guard let window else { return .zero } + let viewRect = NSRect( + x: 0, + y: 0, + width: max(bounds.width, 1), + height: max(bounds.height, 1) + ) + return window.convertToScreen(convert(viewRect, to: nil)) + } + + /// The terminal capture view uses one logical caret position. + public func characterIndex(for point: NSPoint) -> Int { + 0 + } + + /// Handle text-system commands that arrive instead of a raw key event. + override public func doCommand(by selector: Selector) { + guard inputEnabled else { return } + switch selector { + case #selector(insertNewline(_:)): + actions?.sendText("\u{000D}") + case #selector(insertTab(_:)): + actions?.sendText("\u{0009}") + case #selector(deleteBackward(_:)): + actions?.sendText("\u{007F}") + default: + break + } + } + + private func clampedRange(_ range: NSRange, length: Int) -> NSRange { + guard range.location != NSNotFound else { + return NSRange(location: length, length: 0) } + let location = min(max(range.location, 0), length) + let maxLength = max(0, length - location) + return NSRange(location: location, length: min(max(range.length, 0), maxLength)) } } diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift index 1382a7b4bfa0..5603540a240c 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift @@ -31,10 +31,21 @@ public struct HiveViewerRootView: View { .onChange(of: session.workspaces) { _, workspaces in reconcileSelection(workspaces: workspaces) } + .onChange(of: session.phase) { _, phase in + if phase == .connected { + ensureTerminalSession(for: selection) + } + } + .onChange(of: selection) { _, newSelection in + ensureTerminalSession(for: newSelection) + } .task { // The first list arrival picks the host's selected workspace. reconcileSelection(workspaces: session.workspaces) } + .onDisappear { + tearDownTerminalSessions() + } } @ViewBuilder @@ -42,14 +53,17 @@ public struct HiveViewerRootView: View { switch session.phase { case .idle, .connecting: ProgressView(String(localized: "hive.viewer.connecting", defaultValue: "Connecting…")) - case .failed(let message): + case .failed: ContentUnavailableView { Label( String(localized: "hive.viewer.failed.title", defaultValue: "Couldn't Connect"), systemImage: "wifi.exclamationmark" ) } description: { - Text(message) + Text(String( + localized: "hive.viewer.error.connection", + defaultValue: "The other Mac couldn't be reached. Check that it is online and paired, then try again." + )) } actions: { Button(String(localized: "hive.viewer.retry", defaultValue: "Retry")) { session.connect() @@ -76,8 +90,12 @@ public struct HiveViewerRootView: View { } private func terminalSession(for selection: HiveViewerSelection) -> HiveRemoteTerminalSession? { - if let existing = terminalSessions[selection] { return existing } - guard let client = session.client else { return nil } + terminalSessions[selection] + } + + private func ensureTerminalSession(for selection: HiveViewerSelection?) { + guard let selection, terminalSessions[selection] == nil, + let client = session.client else { return } let terminal = HiveRemoteTerminalSession( client: client, workspaceID: selection.workspaceID, @@ -87,10 +105,17 @@ public struct HiveViewerRootView: View { } ) terminalSessions[selection] = terminal - return terminal + } + + private func tearDownTerminalSessions() { + for terminal in terminalSessions.values { + terminal.detach() + } + terminalSessions.removeAll(keepingCapacity: true) } private func reconcileSelection(workspaces: [HiveRemoteWorkspace]) { + pruneStaleTerminalSessions(workspaces: workspaces) if let selection, workspaces.contains(where: { workspace in workspace.id == selection.workspaceID @@ -105,10 +130,23 @@ public struct HiveViewerRootView: View { } selection = HiveViewerSelection(workspaceID: workspace.id, terminalID: terminal.id) } + + private func pruneStaleTerminalSessions(workspaces: [HiveRemoteWorkspace]) { + let liveSelections = Set( + workspaces.flatMap { workspace in + workspace.terminals.map { + HiveViewerSelection(workspaceID: workspace.id, terminalID: $0.id) + } + } + ) + for key in terminalSessions.keys where !liveSelections.contains(key) { + terminalSessions.removeValue(forKey: key)?.detach() + } + } } /// One selectable terminal in the viewer sidebar. -public struct HiveViewerSelection: Hashable, Sendable { +public nonisolated struct HiveViewerSelection: Hashable, Sendable { /// The host workspace id. public let workspaceID: String /// The host surface id. @@ -119,4 +157,3 @@ public struct HiveViewerSelection: Hashable, Sendable { self.terminalID = terminalID } } - diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 03fab5997658..9a136accda65 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -83,7 +83,11 @@ public struct ComputersSection: View { // button; a bounded cadence delay cancelled with the view's task, // not condition-polling. while !Task.isCancelled { - try? await Task.sleep(for: .seconds(30)) + do { + try await ContinuousClock().sleep(for: .seconds(30)) + } catch is CancellationError { + break + } guard !Task.isCancelled else { break } hostActions.refreshComputers() } @@ -431,6 +435,7 @@ private struct ComputersSectionRow: View { } .buttonStyle(.bordered) .controlSize(.small) + .disabled(isPending) .accessibilityIdentifier("SettingsComputersOpenButton-\(computer.deviceID)") Button(String(localized: "settings.computers.row.unpair", defaultValue: "Unpair")) { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7425e9a0c7b7..dd38a20575fd 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -110203,6 +110203,23 @@ } } }, + "hive.viewer.error.connection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The other Mac couldn't be reached. Check that it is online and paired, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続先の Mac に到達できませんでした。オンラインでペアリング済みか確認して、もう一度お試しください。" + } + } + } + }, "hive.viewer.failed.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift index ed98442bc4d9..36700ed76814 100644 --- a/Sources/Auth/HostAccountFlow.swift +++ b/Sources/Auth/HostAccountFlow.swift @@ -80,6 +80,7 @@ final class HostAccountFlow: AccountFlow { func signOut() async { await browserSignIn.signOut() + await HiveComputersService.shared.disconnectAll() isProActive = false canManageBilling = false } diff --git a/Sources/ContentView+AuthCommandPalette.swift b/Sources/ContentView+AuthCommandPalette.swift index efcf600fc6d6..2bea5a1531fa 100644 --- a/Sources/ContentView+AuthCommandPalette.swift +++ b/Sources/ContentView+AuthCommandPalette.swift @@ -56,6 +56,7 @@ extension ContentView { } Task { @MainActor in await auth.browserSignIn.signOut() + await HiveComputersService.shared.disconnectAll() } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1b10fd4c0c3e..cb501cee52bf 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1609,7 +1609,7 @@ struct ContentView: View { ) }, observedWindow: observedWindow, - hiveScope: HiveSidebarScopeModel.scopeModel(for: tabManager), + hiveScope: tabManager.hiveSidebarScopeModel, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, sidebarRenderWorkerClient: $sidebarRenderWorkerClient ) diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index 7d906b00092c..f9ae66914c19 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -2,6 +2,7 @@ import CmuxHive import CmuxMobileRPC import CmuxSettings import CmuxTerminal +import AppKit import Foundation /// Presents a paired remote Mac's cmux workspaces as NATIVE local mirror @@ -26,6 +27,7 @@ final class HiveComputerMirrorController { var computerName: String = "" weak var tabManager: TabManager? var reconcileTask: Task? + var windowCloseObserver: NSObjectProtocol? /// Local mirror workspace id per remote workspace id. var workspaceIdByRemoteID: [String: UUID] = [:] /// Terminal streams per remote terminal id. @@ -115,6 +117,7 @@ final class HiveComputerMirrorController { // Native mirrors: the computer's workspaces join the main // sidebar as real workspaces. context.sidebarSelectionState.selection = .tabs + context.tabManager.hiveSidebarScopeModel.scope = .device(deviceID) _ = await HiveComputerMirrorController.shared.attach( deviceID: deviceID, into: context.tabManager @@ -125,18 +128,12 @@ final class HiveComputerMirrorController { // window, scope its sidebar to the device, attach mirrors. guard let appDelegate = AppDelegate.shared else { return } let windowId = appDelegate.createMainWindow(shouldActivate: true) - var context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } - var attempts = 0 - while context == nil, attempts < 20 { - try? await Task.sleep(for: .milliseconds(100)) - context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } - attempts += 1 - } + let context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } guard let context else { cmuxDebugLog("hive.presentViewer.windowContextMissing windowId=\(windowId)") return } - HiveSidebarScopeModel.scopeModel(for: context.tabManager).scope = .device(deviceID) + context.tabManager.hiveSidebarScopeModel.scope = .device(deviceID) let attached = await HiveComputerMirrorController.shared.attach( deviceID: deviceID, into: context.tabManager @@ -157,7 +154,7 @@ final class HiveComputerMirrorController { tabManager.selectWorkspace(workspace) return firstId } - existing.reconcileTask?.cancel() + await teardownMirror(deviceID: deviceID, mirror: existing) mirrorsByDeviceID.removeValue(forKey: deviceID) } guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID) else { @@ -170,6 +167,18 @@ final class HiveComputerMirrorController { .first(where: { $0.deviceID == deviceID })?.displayName ?? session.displayName mirrorsByDeviceID[deviceID] = mirror + if let window = tabManager.window { + mirror.windowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [weak self, weak tabManager] _ in + Task { @MainActor [weak self, weak tabManager] in + guard let self, let tabManager else { return } + await self.detach(deviceID: deviceID, from: tabManager) + } + } + } mirror.reconcileTask = Task { @MainActor [weak self, weak mirror] in for await workspaces in session.workspaceUpdates() { @@ -182,7 +191,11 @@ final class HiveComputerMirrorController { // a mirror workspace; reconciliation keeps running either way. var attempts = 0 while mirror.workspaceIdByRemoteID.isEmpty, attempts < 40 { - try? await Task.sleep(for: .milliseconds(250)) + do { + try await ContinuousClock().sleep(for: .milliseconds(250)) + } catch is CancellationError { + return nil + } attempts += 1 } if let firstId = mirror.workspaceIdByRemoteID.values.first, @@ -195,14 +208,45 @@ final class HiveComputerMirrorController { /// Detaches a computer's mirrors: stops the streams and closes the /// mirror workspaces. - func detach(deviceID: String, from tabManager: TabManager) { + func detach(deviceID: String, from _: TabManager) async { + guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } + await teardownMirror(deviceID: deviceID, mirror: mirror) + } + + /// Tear down every embedded mirror, used when account auth ends. + func detachAll() async { + let mirrors = mirrorsByDeviceID + mirrorsByDeviceID.removeAll() + for (deviceID, mirror) in mirrors { + await teardownMirror(deviceID: deviceID, mirror: mirror) + } + } + + /// Tear down one device's mirror regardless of which window owns it. + func detach(deviceID: String) async { guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } + await teardownMirror(deviceID: deviceID, mirror: mirror) + } + + private func teardownMirror(deviceID: String, mirror: DeviceMirror) async { mirror.reconcileTask?.cancel() + mirror.reconcileTask = nil + if let observer = mirror.windowCloseObserver { + NotificationCenter.default.removeObserver(observer) + mirror.windowCloseObserver = nil + } for (_, terminal) in mirror.terminalsByRemoteID { terminal.detach() } - for (_, workspaceId) in mirror.workspaceIdByRemoteID { - guard let workspace = tabManager.workspacesById[workspaceId] else { continue } - tabManager.closeWorkspace(workspace) + if let tabManager = mirror.tabManager { + for (_, workspaceId) in mirror.workspaceIdByRemoteID { + guard let workspace = tabManager.workspacesById[workspaceId] else { continue } + tabManager.closeWorkspace(workspace) + } } + mirror.terminalsByRemoteID.removeAll() + mirror.workspaceIdByRemoteID.removeAll() + mirror.panelIdByRemoteTerminalID.removeAll() + mirror.terminalIDsByRemoteWorkspaceID.removeAll() + await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) } // MARK: - Reconcile diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 7d5dd4e96227..2b8618c32e74 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -1,6 +1,7 @@ import CmuxAuthRuntime import CmuxHive import Foundation +import OSLog /// App-side owner of the hive computers directory (the merged registry + /// pairings + presence list behind Settings › Computers). @@ -45,7 +46,8 @@ final class HiveComputersService { do { directory = try composition.makeDirectory() } catch { - NSLog("cmux.hive paired-computer store unavailable: %@", String(describing: error)) + Logger(subsystem: "com.cmux.app", category: "HiveComputersService") + .error("Paired-computer store unavailable: \(String(describing: error), privacy: .public)") } } @@ -79,7 +81,14 @@ final class HiveComputersService { routes: best.routes, retryDelay: { @Sendable attempt in await HiveReconnectBackoff().delay(attempt: attempt) - } + }, + // Only registry-backed rows have authenticated device provenance + // for the encrypted Tailscale trust mode. Manual/pasted links keep + // the safe loopback-only policy, so they can never receive a Stack + // bearer token over an arbitrary tailnet endpoint. + stackAuthChannelTrust: computer.isRegistryBacked + ? .loopbackAndTailscaleTunnel + : .loopbackOnly ) } @@ -110,6 +119,17 @@ final class HiveComputersService { await session.disconnect() } + /// Tear down all remote viewer sessions when the account is signed out. + func disconnectAll() async { + await HiveComputerMirrorController.shared.detachAll() + await HiveViewerWindowController.shared.closeAll() + let sessions = embeddedSessions.values + embeddedSessions.removeAll() + for session in sessions { + await session.disconnect() + } + } + /// The live connection phase for a device's embedded viewer session /// (Settings Open, sidebar scope picker, or `hive.open`), if one has been /// created. `nil` before any attach attempt — callers treat that as "never diff --git a/Sources/Hive/HiveSidebarConnectionStatusView.swift b/Sources/Hive/HiveSidebarConnectionStatusView.swift index 5058552c142b..543fa255a733 100644 --- a/Sources/Hive/HiveSidebarConnectionStatusView.swift +++ b/Sources/Hive/HiveSidebarConnectionStatusView.swift @@ -17,7 +17,7 @@ struct HiveSidebarConnectionStatusView: View { /// No attach attempt has been observed yet. case neverAttempted case connecting - case failed(message: String) + case failed /// The session is connected but the remote genuinely has zero /// workspaces (or the user closed every mirrored one locally). case connectedEmpty @@ -30,8 +30,8 @@ struct HiveSidebarConnectionStatusView: View { self = .connecting case .connected: self = .connectedEmpty - case .failed(let message): - self = .failed(message: message) + case .failed: + self = .failed } } } @@ -52,13 +52,16 @@ struct HiveSidebarConnectionStatusView: View { Text(String(localized: "hive.viewer.connecting", defaultValue: "Connecting…")) .font(.callout) .foregroundStyle(.secondary) - case .failed(let message): + case .failed: Image(systemName: "wifi.exclamationmark") .font(.title2) .foregroundStyle(.secondary) Text(String(localized: "hive.viewer.failed.title", defaultValue: "Couldn't Connect")) .font(.callout) - Text(message) + Text(String( + localized: "hive.viewer.error.connection", + defaultValue: "The other Mac couldn't be reached. Check that it is online and paired, then try again." + )) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/Sources/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift index 50eb29b9dff5..3f36434744b7 100644 --- a/Sources/Hive/HiveViewerWindowController.swift +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -79,4 +79,14 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { await session.disconnect() } } + + /// Close every standalone viewer and stop its RPC session during sign-out. + func closeAll() async { + let entries = viewersByDeviceID + viewersByDeviceID.removeAll() + for entry in entries.values { + entry.window.close() + await entry.session.disconnect() + } + } } diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index 25a7de0e55dc..4ab64f41ac9d 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -60,7 +60,7 @@ extension HostSettingsActions { func unpairComputer(deviceID: String) async { guard let directory = computersDirectory else { return } await directory.unpair(deviceID: deviceID) - await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) + await HiveComputerMirrorController.shared.detach(deviceID: deviceID) } func openComputerViewer(deviceID: String) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index ed45f45e2a5a..a8a8a2c71578 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -179,6 +179,8 @@ class TabManager: ObservableObject { /// The window that owns this TabManager. Set by AppDelegate.registerMainWindow(). /// Used to apply title updates to the correct window instead of NSApp.keyWindow. weak var window: NSWindow? + /// The sidebar computer scope owned by this window's TabManager. + let hiveSidebarScopeModel = HiveSidebarScopeModel() /// Stable identifier of the owning macOS window. Used only for opt-in title /// templates that expose a WM-matchable per-window token. var windowId: UUID? diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index 05aa3b60887e..32057f661e3a 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -105,7 +105,7 @@ extension TerminalController: ControlMobileHostContext { let contexts = AppDelegate.shared.map { Array($0.mainWindowContexts.values) } ?? [] for context in contexts { let tabManager = context.tabManager - let scope = HiveSidebarScopeModel.scopeModel(for: tabManager).scope + let scope = tabManager.hiveSidebarScopeModel.scope let allTabs = tabManager.tabs let visibleTabs = scope == .allComputers ? allTabs diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index 0df19d5c3fdd..d5a903b171f1 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -35,17 +35,6 @@ enum HiveSidebarScope: Equatable { final class HiveSidebarScopeModel: ObservableObject { @Published var scope: HiveSidebarScope = .thisMac - private static var modelsByTabManager: [ObjectIdentifier: HiveSidebarScopeModel] = [:] - - /// The scope model for one window's TabManager (created on first use). - static func scopeModel(for tabManager: TabManager) -> HiveSidebarScopeModel { - let key = ObjectIdentifier(tabManager) - if let existing = modelsByTabManager[key] { return existing } - let model = HiveSidebarScopeModel() - modelsByTabManager[key] = model - return model - } - /// Whether `workspace` is visible under the current scope, given the /// device that owns it (`nil` for local workspaces). nonisolated static func isVisible(deviceID: String?, scope: HiveSidebarScope) -> Bool { diff --git a/scripts/lint-ios-package-conventions.sh b/scripts/lint-ios-package-conventions.sh index 9f758862cb73..37e1af603a30 100755 --- a/scripts/lint-ios-package-conventions.sh +++ b/scripts/lint-ios-package-conventions.sh @@ -19,7 +19,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" SCOPES=() -for d in Packages/Shared/CMUXMobileCore Packages/iOS/CmuxMobile* Packages/Shared/CmuxAgentChat Packages/iOS/CmuxAgentChatUI Packages/Shared/CmuxSyncStore ios/cmuxPackage/Sources ios/cmux; do +for d in Packages/Shared/CMUXMobileCore Packages/iOS/CmuxMobile* Packages/Shared/CmuxMobile* Packages/Shared/CmuxAgentChat Packages/iOS/CmuxAgentChatUI Packages/Shared/CmuxSyncStore ios/cmuxPackage/Sources ios/cmux; do [ -d "$d" ] && SCOPES+=("$d") done From a7d022cf480b244d8847f63a537f90e06959b34c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 17:35:09 -0700 Subject: [PATCH 34/97] Hive mirrors: reset scope during teardown --- Sources/Hive/HiveComputerMirrorController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index f9ae66914c19..bbc6d2a0ebd4 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -237,6 +237,7 @@ final class HiveComputerMirrorController { } for (_, terminal) in mirror.terminalsByRemoteID { terminal.detach() } if let tabManager = mirror.tabManager { + tabManager.hiveSidebarScopeModel.scope = .thisMac for (_, workspaceId) in mirror.workspaceIdByRemoteID { guard let workspace = tabManager.workspacesById[workspaceId] else { continue } tabManager.closeWorkspace(workspace) From 294e7d90603f7a6b17db1bd327fb298e34ef8800 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 17:43:06 -0700 Subject: [PATCH 35/97] Hive directory: avoid idle presence subscriptions --- .../CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index c77a4c11376f..bc5d80ad323a 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -105,7 +105,9 @@ public final class HiveComputerDirectory { self?.removeListener(id: id) } } - startPresenceIfNeeded() + if !listeners.isEmpty { + startPresenceIfNeeded() + } return stream } @@ -186,7 +188,9 @@ public final class HiveComputerDirectory { for (_, continuation) in listeners { continuation.yield(computers) } - startPresenceIfNeeded() + if !listeners.isEmpty { + startPresenceIfNeeded() + } return scopeGeneration } From 61c025b75bde2e5f850ee31229a3d7b9c825d53d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 17:48:41 -0700 Subject: [PATCH 36/97] Hive viewer: enforce pairing and recovery boundaries --- .../CmuxHive/HiveComputerDirectory.swift | 50 ++++++++++++++++++- .../CmuxHive/HiveRemoteMacSession.swift | 26 +++++++++- .../CmuxHive/HiveRemoteTerminalSession.swift | 36 +++++++++++-- .../HiveRemoteTerminalSessionError.swift | 1 + .../Hive/HiveComputerMirrorController.swift | 12 +++++ Sources/Hive/HiveComputersService.swift | 6 ++- Sources/HostSettingsActions+Computers.swift | 11 +++- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 23 ++++++++- 8 files changed, 155 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index bc5d80ad323a..c2de3f85ad50 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -47,6 +47,7 @@ public final class HiveComputerDirectory { @ObservationIgnored private var listeners: [UUID: AsyncStream<[HiveComputer]>.Continuation] = [:] @ObservationIgnored private var presenceTask: Task? @ObservationIgnored private var presenceGeneration = 0 + @ObservationIgnored private var refreshTask: Task? /// Creates a directory over injected source seams. /// @@ -127,11 +128,29 @@ public final class HiveComputerDirectory { /// rebuild the merged rows. Transient registry failures keep the previous /// registry data; an auth rejection clears it (the scope changed). public func refresh() async { + if let refreshTask { + await refreshTask.value + return + } + let task = Task { [weak self] in + await self?.performRefresh() + } + refreshTask = task + await task.value + refreshTask = nil + } + + private func performRefresh() async { guard !isRefreshing else { return } isRefreshing = true defer { isRefreshing = false } let scope = await scopeProvider() let generation = activateScope(scope) + guard scope.stackUserID != nil else { + clearLoadedSources() + rebuild() + return + } switch await registry.listDevices() { case .ok(let devices): guard await isCurrentScope(scope, generation: generation) else { return } @@ -157,6 +176,12 @@ public final class HiveComputerDirectory { } private func reloadPairedRecords(scope: HiveAccountScope) async { + guard scope.stackUserID != nil else { + pairedRecords = [] + pairedByID = [:] + pairedRecordsByID = [:] + return + } do { pairedRecords = try await pairedStore.loadAll( stackUserID: scope.stackUserID, @@ -170,6 +195,27 @@ public final class HiveComputerDirectory { } } + /// Clear account-owned rows when auth transitions to signed out. + public func clearForSignOut() { + _ = activateScope(HiveAccountScope(stackUserID: nil, teamID: nil)) + clearLoadedSources() + rebuild() + } + + private func clearLoadedSources() { + registryDevices = [] + pairedRecords = [] + registryByID = [:] + pairedByID = [:] + pairedRecordsByID = [:] + presenceMap = PresenceMap() + presenceGeneration &+= 1 + presenceTask?.cancel() + presenceTask = nil + computers = [] + lastRefreshFailed = false + } + private func activateScope(_ scope: HiveAccountScope) -> Int { guard loadedScope != scope else { return scopeGeneration } loadedScope = scope @@ -210,6 +256,7 @@ public final class HiveComputerDirectory { /// routes into the local paired store. public func pair(deviceID: String) async -> HivePairOutcome { let scope = await scopeProvider() + guard scope.stackUserID != nil else { return .storeFailed } if loadedScope != scope { await refresh() guard loadedScope == scope else { return .noRoutes } @@ -248,7 +295,7 @@ public final class HiveComputerDirectory { } await refresh() let scope = await scopeProvider() - guard loadedScope == scope else { return .codeNotFound } + guard scope.stackUserID != nil, loadedScope == scope else { return .codeNotFound } let claimTime = now() let matches = registryDevices.flatMap { device in device.instances @@ -271,6 +318,7 @@ public final class HiveComputerDirectory { /// Pair from a pasted pairing link (the QR payload another Mac shows). public func pair(link rawLink: String) async -> HivePairOutcome { let scope = await scopeProvider() + guard scope.stackUserID != nil else { return .accountMismatch } if loadedScope == nil { _ = activateScope(scope) } else if loadedScope != scope { diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index 4727a10be601..375795c02ce7 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -46,6 +46,8 @@ public final class HiveRemoteMacSession { @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void @ObservationIgnored private let workspaceDecoder: HiveRemoteWorkspaceDecoder @ObservationIgnored private let stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust + @ObservationIgnored private let expectedInstanceTag: String? + @ObservationIgnored private let requiresHostIdentity: Bool /// The connected RPC client terminal sessions share, `nil` until the /// first successful connect. @ObservationIgnored public private(set) var client: MobileCoreRPCClient? @@ -66,13 +68,18 @@ public final class HiveRemoteMacSession { /// sleep, tests a recorder that returns immediately. /// - stackAuthChannelTrust: The route provenance trusted to carry the /// account token. Manual links default to loopback-only. + /// - expectedInstanceTag: The registry instance tag, when known. + /// - requiresHostIdentity: Require mobile.host.status to match the + /// registry device before accepting workspace data. public init( runtime: any MobileSyncRuntime, macDeviceID: String, displayName: String, routes: [CmxAttachRoute], retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void, - stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust = .loopbackOnly + stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust = .loopbackOnly, + expectedInstanceTag: String? = nil, + requiresHostIdentity: Bool = false ) { self.runtime = runtime self.macDeviceID = macDeviceID @@ -81,6 +88,8 @@ public final class HiveRemoteMacSession { self.retryDelay = retryDelay self.workspaceDecoder = HiveRemoteWorkspaceDecoder() self.stackAuthChannelTrust = stackAuthChannelTrust + self.expectedInstanceTag = expectedInstanceTag + self.requiresHostIdentity = requiresHostIdentity } /// Start (or restart) the session: dial routes, fetch the workspace list, @@ -180,6 +189,9 @@ public final class HiveRemoteMacSession { stackAuthChannelTrust: stackAuthChannelTrust ) do { + if requiresHostIdentity { + try await verifyHostIdentity(client: candidate) + } let workspaces = try await fetchWorkspaces(client: candidate) // disconnect() can cancel this task while the RPC is // suspended. Do not resurrect a session after teardown. @@ -233,6 +245,18 @@ public final class HiveRemoteMacSession { }.value } + private func verifyHostIdentity(client: MobileCoreRPCClient) async throws { + let request = try MobileCoreRPCClient.requestData(method: "mobile.host.status") + let data = try await client.sendRequest(request) + let status = try await Task.detached(priority: .userInitiated) { + try MobileHostStatusResponse.decode(data) + }.value + guard status.macDeviceID == macDeviceID, + expectedInstanceTag == nil || status.macInstanceTag == expectedInstanceTag else { + throw HiveRemoteTerminalSessionError.hostIdentityMismatch + } + } + // MARK: - Events private func startEventLoop(client: MobileCoreRPCClient) { diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift index d22ac70e5a20..6ff26d3d1610 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -56,6 +56,8 @@ public final class HiveRemoteTerminalSession { @ObservationIgnored private var frameQueueNeedsReplay = false @ObservationIgnored private var inputTask: Task? @ObservationIgnored private var pendingInput: [String] = [] + @ObservationIgnored private var pendingInputBytes = 0 + @ObservationIgnored private let maximumPendingInputBytes = 64 * 1024 @ObservationIgnored private var inputGeneration = 0 /// Creates a terminal view session over an already-connected client. @@ -103,6 +105,7 @@ public final class HiveRemoteTerminalSession { inputTask?.cancel() inputTask = nil pendingInput.removeAll(keepingCapacity: true) + pendingInputBytes = 0 phase = .idle } @@ -150,7 +153,7 @@ public final class HiveRemoteTerminalSession { private func sendInput(_ text: String) { guard phase == .live || phase == .reattaching else { return } - pendingInput.append(text) + enqueueInput(text) startInputWorkerIfNeeded() } @@ -166,6 +169,7 @@ public final class HiveRemoteTerminalSession { } while !Task.isCancelled { guard let text = self.dequeueInput(for: generation) else { break } + var requestStarted = false do { let request = try MobileCoreRPCClient.requestData( method: "mobile.terminal.input", @@ -176,13 +180,23 @@ public final class HiveRemoteTerminalSession { ] ) try Task.checkCancellation() + requestStarted = true _ = try await self.client.sendRequest(request) } catch is CancellationError { return } catch { // Keep the lifecycle truthful when an input request fails; // the attach loop will re-establish the stream. - pendingInput.insert(text, at: 0) + if !requestStarted { + enqueueInput(text, atFront: true) + } else { + // A lost response is ambiguous: the host may already + // have applied these non-idempotent bytes. Drop the + // remainder rather than duplicating a command after + // reconnect; the phase change tells the user to retry. + pendingInput.removeAll(keepingCapacity: true) + pendingInputBytes = 0 + } if self.inputGeneration == generation, self.phase == .live { self.phase = .reattaching self.attachTask?.cancel() @@ -197,7 +211,23 @@ public final class HiveRemoteTerminalSession { private func dequeueInput(for generation: Int) -> String? { guard generation == inputGeneration, phase == .live, !pendingInput.isEmpty else { return nil } - return pendingInput.removeFirst() + let text = pendingInput.removeFirst() + pendingInputBytes -= text.utf8.count + return text + } + + private func enqueueInput(_ text: String, atFront: Bool = false) { + let bytes = text.utf8.count + guard bytes <= maximumPendingInputBytes else { return } + while pendingInputBytes + bytes > maximumPendingInputBytes, !pendingInput.isEmpty { + pendingInputBytes -= pendingInput.removeFirst().utf8.count + } + if atFront { + pendingInput.insert(text, at: 0) + } else { + pendingInput.append(text) + } + pendingInputBytes += bytes } // MARK: - Attach loop diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift index 7951958d4b19..486fc3543b18 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift @@ -3,4 +3,5 @@ enum HiveRemoteTerminalSessionError: Error { case missingFrame case incompleteFrame case mismatchedSurface + case hostIdentityMismatch } diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index bbc6d2a0ebd4..9ad61da370c1 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -298,6 +298,18 @@ final class HiveComputerMirrorController { for remote in workspaces { if let workspaceId = mirror.workspaceIdByRemoteID[remote.id], let workspace = tabManager.workspacesById[workspaceId] { + let nextWorkspaceTitle = String( + localized: "hive.mirror.workspaceTitle", + defaultValue: "(remote.title) — (mirror.computerName)" + ) + if workspace.title != nextWorkspaceTitle { + workspace.title = nextWorkspaceTitle + } + for terminal in remote.terminals { + if let panelID = mirror.panelIdByRemoteTerminalID[terminal.id] { + workspace.updateRemoteTmuxTabTitle(panelId: panelID, title: terminal.title) + } + } addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, client: client) continue } diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 2b8618c32e74..45b9216ef0b6 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -66,6 +66,7 @@ final class HiveComputersService { computer = directory.computers.first(where: { $0.deviceID == deviceID }) } guard let computer else { return nil } + guard computer.isPaired else { return nil } // Prefer the freshest routes: a live online instance's advertised set, // falling back to whatever the pairing/registry row carries. guard let best = computer.bestPairingRoutes else { return nil } @@ -88,7 +89,9 @@ final class HiveComputersService { // bearer token over an arbitrary tailnet endpoint. stackAuthChannelTrust: computer.isRegistryBacked ? .loopbackAndTailscaleTunnel - : .loopbackOnly + : .loopbackOnly, + expectedInstanceTag: best.instanceTag, + requiresHostIdentity: computer.isRegistryBacked ) } @@ -128,6 +131,7 @@ final class HiveComputersService { for session in sessions { await session.disconnect() } + directory?.clearForSignOut() } /// The live connection phase for a device's embedded viewer session diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index 4ab64f41ac9d..b8691cd3ebb6 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -83,9 +83,16 @@ extension HostSettingsActions { let host = MobileHostService.shared let status = await host.ensureListeningAndReady() guard status.isRunning else { return .failed } - guard !status.routes.isEmpty else { return .needsTailscale } + let supportedRoutes = status.routes.filter { route in + if route.kind == .tailscale { return true } +#if DEBUG + if route.kind == .debugLoopback { return true } +#endif + return false + } + guard !supportedRoutes.isEmpty else { return .needsTailscale } guard let minted = await DeviceRegistryClient.shared.publishPairingCode( - routes: status.routes + routes: supportedRoutes ) else { return .failed } return .minted(code: minted.code, expiresAt: minted.expiresAt) } diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index d5a903b171f1..f1ef0c96a734 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -98,13 +98,20 @@ struct HiveSidebarScopePicker: View { case .allComputers: deviceIDs = computers.map(\.id) case .device(let id): deviceIDs = [id] } - guard !deviceIDs.isEmpty else { return } + if deviceIDs.isEmpty { + selectFirstVisibleWorkspace(scope: scope, manager: manager) + return + } Task { @MainActor in for deviceID in deviceIDs { - _ = await HiveComputerMirrorController.shared.attach( + let workspaceID = await HiveComputerMirrorController.shared.attach( deviceID: deviceID, into: manager ) + guard scopeModel.scope == scope else { return } + if let workspaceID, let workspace = manager.workspacesById[workspaceID] { + manager.selectWorkspace(workspace) + } } } } label: { @@ -141,6 +148,18 @@ struct HiveSidebarScopePicker: View { if snapshot != computers { computers = snapshot } } } + + private func selectFirstVisibleWorkspace(scope: HiveSidebarScope, manager: TabManager) { + let workspace = manager.tabs.first { workspace in + HiveSidebarScopeModel.isVisible( + deviceID: HiveComputerMirrorController.shared.deviceID(forWorkspace: workspace.id), + scope: scope + ) + } + if let workspace { + manager.selectWorkspace(workspace) + } + } } /// Footer debug controls and empty-area drop targets for the vertical tabs sidebar, extracted from `ContentView.swift`, which sits at its file-length budget. From 3784099cb12f8ca3aba4c45c0d4d093bf43aa60a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 17:56:03 -0700 Subject: [PATCH 37/97] Hive viewer: require verified host identity --- .../CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift | 2 +- .../Tests/CmuxHiveTests/HiveRemoteSessionTests.swift | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index 375795c02ce7..9e8b64191928 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -79,7 +79,7 @@ public final class HiveRemoteMacSession { retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void, stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust = .loopbackOnly, expectedInstanceTag: String? = nil, - requiresHostIdentity: Bool = false + requiresHostIdentity: Bool = true ) { self.runtime = runtime self.macDeviceID = macDeviceID diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift index ba36560566a1..a74d65f398a5 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift @@ -86,7 +86,8 @@ private func jsonObject(_ string: String) -> [String: Any] { displayName: "Studio", routes: [try tailscaleRoute()], retryDelay: { _ in }, - stackAuthChannelTrust: .loopbackAndTailscaleTunnel + stackAuthChannelTrust: .loopbackAndTailscaleTunnel, + requiresHostIdentity: false ) session.connect() await transport.waitForMethod("mobile.events.subscribe") @@ -136,7 +137,8 @@ private func jsonObject(_ string: String) -> [String: Any] { displayName: "Studio", routes: [try tailscaleRoute()], retryDelay: { _ in }, - stackAuthChannelTrust: .loopbackAndTailscaleTunnel + stackAuthChannelTrust: .loopbackAndTailscaleTunnel, + requiresHostIdentity: false ) macSession.connect() try await waitUntil { macSession.phase == .connected } @@ -218,7 +220,8 @@ private func jsonObject(_ string: String) -> [String: Any] { displayName: "Studio", routes: [try tailscaleRoute()], retryDelay: { _ in }, - stackAuthChannelTrust: .loopbackAndTailscaleTunnel + stackAuthChannelTrust: .loopbackAndTailscaleTunnel, + requiresHostIdentity: false ) macSession.connect() try await waitUntil { macSession.phase == .connected } From 12d61d08dea353bd270354d27405a2fe80086ff5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 18:30:30 -0700 Subject: [PATCH 38/97] Hive viewer: reconcile session routes and mirror titles --- .../CmuxHive/HiveComputerDirectory.swift | 14 +++++++- .../CmuxHive/HiveRemoteMacSession.swift | 32 +++++++++++++++++-- .../Sources/CmuxHive/HiveSyncRuntime.swift | 6 +++- .../Hive/HiveComputerMirrorController.swift | 18 +++++++---- Sources/Hive/HiveComputersService.swift | 14 ++++++-- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index c2de3f85ad50..df54ccc20cb3 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -133,7 +133,8 @@ public final class HiveComputerDirectory { return } let task = Task { [weak self] in - await self?.performRefresh() + guard let self else { return } + await self.performRefresh() } refreshTask = task await task.value @@ -390,6 +391,7 @@ public final class HiveComputerDirectory { return false } await reloadPairedRecords(scope: scope) + guard await isCurrentScope(scope, generation: scopeGeneration) else { return false } rebuild() return true } @@ -402,6 +404,10 @@ public final class HiveComputerDirectory { scope: HiveAccountScope ) async -> HivePairOutcome { guard !routes.isEmpty else { return .noRoutes } + let generation = scopeGeneration + guard await isCurrentScope(scope, generation: generation) else { + return .accountMismatch + } do { try await pairedStore.upsert( macDeviceID: macDeviceID, @@ -416,7 +422,13 @@ public final class HiveComputerDirectory { } catch { return .storeFailed } + guard await isCurrentScope(scope, generation: generation) else { + return .accountMismatch + } await reloadPairedRecords(scope: scope) + guard await isCurrentScope(scope, generation: generation) else { + return .accountMismatch + } rebuild() return .paired(deviceID: macDeviceID) } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index 9e8b64191928..d68348f588d8 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -35,6 +35,9 @@ public final class HiveRemoteMacSession { public let macDeviceID: String /// The paired Mac's display name (for the window title). public let displayName: String + /// The currently selected route set; a changed registry/presence snapshot + /// causes the app service to replace this session before reconnecting. + public let routes: [CmxAttachRoute] /// Connection lifecycle state. public private(set) var phase: Phase = .idle @@ -42,7 +45,6 @@ public final class HiveRemoteMacSession { public private(set) var workspaces: [HiveRemoteWorkspace] = [] @ObservationIgnored private let runtime: any MobileSyncRuntime - @ObservationIgnored private let routes: [CmxAttachRoute] @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void @ObservationIgnored private let workspaceDecoder: HiveRemoteWorkspaceDecoder @ObservationIgnored private let stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust @@ -53,6 +55,8 @@ public final class HiveRemoteMacSession { @ObservationIgnored public private(set) var client: MobileCoreRPCClient? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var connectTask: Task? + @ObservationIgnored private var workspaceRefreshTask: Task? + @ObservationIgnored private var workspaceRefreshPending = false @ObservationIgnored private var workspaceListeners: [UUID: AsyncStream<[HiveRemoteWorkspace]>.Continuation] = [:] @@ -114,6 +118,9 @@ public final class HiveRemoteMacSession { pendingEvents?.cancel() await pendingEvents?.value eventTask = nil + workspaceRefreshTask?.cancel() + workspaceRefreshTask = nil + workspaceRefreshPending = false if let client { await client.disconnect() } @@ -123,6 +130,27 @@ public final class HiveRemoteMacSession { /// Re-fetch the workspace list from the connected Mac. public func refreshWorkspaces() async { + scheduleWorkspaceRefresh() + await workspaceRefreshTask?.value + } + + private func scheduleWorkspaceRefresh() { + guard workspaceRefreshTask == nil else { + workspaceRefreshPending = true + return + } + workspaceRefreshTask = Task { [weak self] in + await self?.performWorkspaceRefresh() + guard let self else { return } + self.workspaceRefreshTask = nil + if self.workspaceRefreshPending { + self.workspaceRefreshPending = false + self.scheduleWorkspaceRefresh() + } + } + } + + private func performWorkspaceRefresh() async { guard let client else { return } do { setWorkspaces(try await fetchWorkspaces(client: client)) @@ -293,7 +321,7 @@ public final class HiveRemoteMacSession { continue } for await _ in stream { - await refreshWorkspaces() + scheduleWorkspaceRefresh() } // Stream finished: the transport died. Loop to re-subscribe. if Task.isCancelled { return } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift index d29463e9b818..0867d4e2a9f8 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift @@ -55,12 +55,15 @@ public struct HiveSyncRuntime: MobileSyncRuntime, Sendable { /// - allowsLoopbackRoutes: Include `.debugLoopback` (dev builds pairing /// two instances on one machine). /// - stackAccessTokenProvider: Mints the Stack access token. + /// - stackAccessTokenForStatusProvider: Supplies a cached token for + /// authenticated host-identity status probes. /// - stackAccessTokenForceRefresher: Force-mints a fresh token after an /// auth rejection. public static func network( allowsLoopbackRoutes: Bool, stackAccessTokenProvider: @escaping @Sendable () async throws -> String, - stackAccessTokenForceRefresher: @escaping @Sendable () async throws -> String + stackAccessTokenForceRefresher: @escaping @Sendable () async throws -> String, + stackAccessTokenForStatusProvider: @escaping @Sendable () async -> String? = { nil } ) -> HiveSyncRuntime { let supportedKinds: [CmxAttachTransportKind] = allowsLoopbackRoutes ? [.debugLoopback, .tailscale] @@ -89,6 +92,7 @@ public struct HiveSyncRuntime: MobileSyncRuntime, Sendable { supportedRouteKinds: supportedKinds, transportFactory: transportFactory, stackAccessTokenProvider: stackAccessTokenProvider, + stackAccessTokenForStatusProvider: stackAccessTokenForStatusProvider, stackAccessTokenForceRefresher: stackAccessTokenForceRefresher ) } diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index 9ad61da370c1..d8bbf247cf8d 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -298,9 +298,9 @@ final class HiveComputerMirrorController { for remote in workspaces { if let workspaceId = mirror.workspaceIdByRemoteID[remote.id], let workspace = tabManager.workspacesById[workspaceId] { - let nextWorkspaceTitle = String( - localized: "hive.mirror.workspaceTitle", - defaultValue: "(remote.title) — (mirror.computerName)" + let nextWorkspaceTitle = localizedWorkspaceTitle( + remoteTitle: remote.title, + computerName: mirror.computerName ) if workspace.title != nextWorkspaceTitle { workspace.title = nextWorkspaceTitle @@ -313,9 +313,9 @@ final class HiveComputerMirrorController { addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, client: client) continue } - let title = String( - localized: "hive.mirror.workspaceTitle", - defaultValue: "\(remote.title) — \(mirror.computerName)" + let title = localizedWorkspaceTitle( + remoteTitle: remote.title, + computerName: mirror.computerName ) let workspace = tabManager.addWorkspace( title: title, @@ -337,6 +337,12 @@ final class HiveComputerMirrorController { } } + /// Formats a mirror workspace title through the localized format resource + /// so creation and later topology/title updates use identical text. + private func localizedWorkspaceTitle(remoteTitle: String, computerName: String) -> String { + String(localized: "hive.mirror.workspaceTitle", defaultValue: "\(remoteTitle) — \(computerName)") + } + private func addMissingTerminals( remote: HiveRemoteWorkspace, workspace: Workspace, diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 45b9216ef0b6..df136c74a10b 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -73,7 +73,8 @@ final class HiveComputersService { let runtime = HiveSyncRuntime.network( allowsLoopbackRoutes: Self.allowsLoopbackPairing, stackAccessTokenProvider: { try await auth.accessToken() }, - stackAccessTokenForceRefresher: { try await auth.forceRefreshAccessToken() } + stackAccessTokenForceRefresher: { try await auth.forceRefreshAccessToken() }, + stackAccessTokenForStatusProvider: { try? await auth.accessToken() } ) return HiveRemoteMacSession( runtime: runtime, @@ -104,7 +105,16 @@ final class HiveComputersService { /// connecting one on first use. Returns `nil` when the computer has no /// pairing record or auth is not configured. func embeddedSession(deviceID: String) async -> HiveRemoteMacSession? { - if let existing = embeddedSessions[deviceID] { return existing } + if let existing = embeddedSessions[deviceID] { + if let computer = directory?.computers.first(where: { $0.deviceID == deviceID }), + let best = computer.bestPairingRoutes, + best.routes != existing.routes { + embeddedSessions.removeValue(forKey: deviceID) + await existing.disconnect() + } else { + return existing + } + } guard let session = await makeViewerSession(deviceID: deviceID) else { return nil } // Re-check after the await: a concurrent first call may have won. if let existing = embeddedSessions[deviceID] { From 264885036ec7332a31bf0703d4371fe874805903 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 19:05:29 -0700 Subject: [PATCH 39/97] Hive viewer: add grid and event-router regression tests --- .../HiveRemoteSessionTests.swift | 72 ++++++++++++++++++- .../HiveTerminalGridModelTests.swift | 13 ++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift index a74d65f398a5..7b1900aae07d 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift @@ -48,9 +48,13 @@ private func workspaceListResult() -> [String: Any] { /// A full render-grid frame pre-encoded to a JSON string so `@Sendable` /// handler closures can capture it (`[String: Any]` is not Sendable). -private func fullFrameJSONString(text: String, stateSeq: UInt64) throws -> String { +private func fullFrameJSONString( + surfaceID: String = "term-1", + text: String, + stateSeq: UInt64 +) throws -> String { let frame = try MobileTerminalRenderGridFrame( - surfaceID: "term-1", + surfaceID: surfaceID, stateSeq: stateSeq, columns: 20, rows: 5, @@ -248,6 +252,70 @@ private func jsonObject(_ string: String) -> [String: Any] { terminal.detach() await macSession.disconnect() } + + @MainActor + @Test func sharedRenderGridRouterUsesOneHostSubscriptionForMultipleTerminals() async throws { + let replayOne = try fullFrameJSONString(surfaceID: "term-1", text: "one", stateSeq: 1) + let replayTwo = try fullFrameJSONString(surfaceID: "term-2", text: "two", stateSeq: 1) + let transport = ScriptedHostTransport { method, params in + switch method { + case "mobile.workspace.list": + return workspaceListResult() + case "mobile.events.subscribe": + return ["stream_id": "s", "topics": ["workspace.updated", "terminal.render_grid"]] + case "mobile.terminal.replay": + let surfaceID = params["surface_id"] as? String + return [ + "workspace_id": "ws-1", + "surface_id": surfaceID ?? "", + "seq": 1, + "render_grid": jsonObject(surfaceID == "term-2" ? replayTwo : replayOne), + ] + default: + return [:] + } + } + let session = HiveRemoteMacSession( + runtime: makeRuntime(transport: transport), + macDeviceID: "mac-b", + displayName: "Studio", + routes: [try tailscaleRoute()], + retryDelay: { _ in }, + stackAuthChannelTrust: .loopbackAndTailscaleTunnel, + requiresHostIdentity: false + ) + session.connect() + try await waitUntil { session.phase == .connected } + let noDelay: @Sendable (Int) async -> Void = { _ in } + let first = try #require(session.makeTerminalSession( + workspaceID: "ws-1", + terminalID: "term-1", + retryDelay: noDelay + )) + let second = try #require(session.makeTerminalSession( + workspaceID: "ws-1", + terminalID: "term-2", + retryDelay: noDelay + )) + first.attach() + second.attach() + await transport.waitForMethod("mobile.terminal.replay", count: 2) + try await waitUntil { first.phase == .live && second.phase == .live } + + let subscribeCount = await transport.sentMethods.filter { $0 == "mobile.events.subscribe" }.count + #expect(subscribeCount == 1) + + await transport.pushEvent( + topic: "terminal.render_grid", + payload: jsonObject(try fullFrameJSONString(surfaceID: "term-2", text: "updated", stateSeq: 2)) + ) + try await waitUntil { second.grid.plainRow(0) == "updated" } + #expect(first.grid.plainRow(0) == "one") + + first.detach() + second.detach() + await session.disconnect() + } } /// Await a main-actor condition with a bounded deadline, yielding between diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift index 93e142d5cc0f..bb9c202f6482 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift @@ -192,4 +192,17 @@ import Testing #expect(grid.plainRow(0) == "ab cd") #expect(grid.rowSpans[0].first?.totalCellWidth == 6) } + + @Test func rejectsUnboundedRemoteDimensionsBeforeAllocatingRows() throws { + var grid = HiveTerminalGridModel() + let oversizedRows = try fullFrame(columns: 80, rows: Int.max, rowSpans: []) + let oversizedColumns = try fullFrame(columns: Int.max, rows: 24, rowSpans: []) + + let rejectedRows = grid.apply(oversizedRows) + #expect(!rejectedRows) + #expect(!grid.hasContent) + let rejectedColumns = grid.apply(oversizedColumns) + #expect(!rejectedColumns) + #expect(grid.rowSpans.isEmpty) + } } From 94096432d8cdcdaf4cf4b678cd00ffce3c64744d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 19:06:34 -0700 Subject: [PATCH 40/97] Hive viewer: cover unverified Tailscale bearer rejection --- .../HiveTailscaleByteTransportFactoryTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift index 06c816f2e9d8..e1f9e6ed5217 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift @@ -30,4 +30,16 @@ struct HiveTailscaleByteTransportFactoryTests { _ = try factory.makeTransport(for: try route(kind: .debugLoopback, host: "127.0.0.1")) } } + + @Test func rejectsGenericStackBearerRequestsUntilPeerAdmissionIsProven() throws { + let route = try route(kind: .tailscale, host: "100.65.181.35") + let request = CmxByteTransportRequest( + route: route, + expectedPeerDeviceID: "mac-b", + authorizationMode: .stackBearer + ) + #expect(throws: (any Error).self) { + _ = try HiveTailscaleByteTransportFactory().makeTransport(for: request) + } + } } From 9c4d29d1f82b9ad9ea7985059de1fa2d2c15a702 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 19:15:39 -0700 Subject: [PATCH 41/97] Hive viewer: bind render streams and fail closed on Tailscale --- .../CmuxHive/HiveRemoteMacSession.swift | 53 +++++-- .../CmuxHive/HiveRemoteRenderGridRouter.swift | 105 ++++++++++++++ .../CmuxHive/HiveRemoteTerminalSession.swift | 65 ++++++--- .../HiveTailscaleByteTransportFactory.swift | 9 ++ .../CmuxHive/HiveTerminalGridModel.swift | 19 +++ .../CmuxHiveUI/HiveViewerRootView.swift | 6 +- .../Hive/HiveComputerMirrorController.swift | 133 ++++++++++++++---- Sources/Hive/HiveComputersService.swift | 24 ++-- Sources/Hive/HiveViewerWindowController.swift | 70 ++++++++- 9 files changed, 408 insertions(+), 76 deletions(-) create mode 100644 Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index d68348f588d8..895a8e491b96 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -38,6 +38,10 @@ public final class HiveRemoteMacSession { /// The currently selected route set; a changed registry/presence snapshot /// causes the app service to replace this session before reconnecting. public let routes: [CmxAttachRoute] + /// The registry instance tag this session was bound to, when available. + public let expectedInstanceTag: String? + /// Whether this session requires the authenticated host-identity gate. + public let requiresHostIdentity: Bool /// Connection lifecycle state. public private(set) var phase: Phase = .idle @@ -48,11 +52,12 @@ public final class HiveRemoteMacSession { @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void @ObservationIgnored private let workspaceDecoder: HiveRemoteWorkspaceDecoder @ObservationIgnored private let stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust - @ObservationIgnored private let expectedInstanceTag: String? - @ObservationIgnored private let requiresHostIdentity: Bool /// The connected RPC client terminal sessions share, `nil` until the /// first successful connect. @ObservationIgnored public private(set) var client: MobileCoreRPCClient? + /// One surface-scoped render-grid router shared by all terminal sessions + /// attached to this Mac. + @ObservationIgnored public private(set) var renderGridRouter: HiveRemoteRenderGridRouter? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var connectTask: Task? @ObservationIgnored private var workspaceRefreshTask: Task? @@ -89,11 +94,11 @@ public final class HiveRemoteMacSession { self.macDeviceID = macDeviceID self.displayName = displayName self.routes = routes + self.expectedInstanceTag = expectedInstanceTag + self.requiresHostIdentity = requiresHostIdentity self.retryDelay = retryDelay self.workspaceDecoder = HiveRemoteWorkspaceDecoder() self.stackAuthChannelTrust = stackAuthChannelTrust - self.expectedInstanceTag = expectedInstanceTag - self.requiresHostIdentity = requiresHostIdentity } /// Start (or restart) the session: dial routes, fetch the workspace list, @@ -121,6 +126,8 @@ public final class HiveRemoteMacSession { workspaceRefreshTask?.cancel() workspaceRefreshTask = nil workspaceRefreshPending = false + renderGridRouter?.stop() + renderGridRouter = nil if let client { await client.disconnect() } @@ -134,15 +141,40 @@ public final class HiveRemoteMacSession { await workspaceRefreshTask?.value } + /// Creates a terminal session using this Mac's shared surface-scoped event + /// router. Returns `nil` until the initial RPC client is connected. + /// - Parameters: + /// - workspaceID: The remote workspace containing the terminal. + /// - terminalID: The remote terminal/surface identifier. + /// - retryDelay: Backoff used while reattaching after a transport drop. + public func makeTerminalSession( + workspaceID: String, + terminalID: String, + retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void + ) -> HiveRemoteTerminalSession? { + guard let client else { return nil } + return HiveRemoteTerminalSession( + client: client, + workspaceID: workspaceID, + terminalID: terminalID, + retryDelay: retryDelay, + renderGridRouter: renderGridRouter + ) + } + private func scheduleWorkspaceRefresh() { guard workspaceRefreshTask == nil else { workspaceRefreshPending = true return } workspaceRefreshTask = Task { [weak self] in - await self?.performWorkspaceRefresh() guard let self else { return } + await self.performWorkspaceRefresh() self.workspaceRefreshTask = nil + guard !Task.isCancelled else { + self.workspaceRefreshPending = false + return + } if self.workspaceRefreshPending { self.workspaceRefreshPending = false self.scheduleWorkspaceRefresh() @@ -151,9 +183,11 @@ public final class HiveRemoteMacSession { } private func performWorkspaceRefresh() async { - guard let client else { return } + guard !Task.isCancelled, let client else { return } do { - setWorkspaces(try await fetchWorkspaces(client: client)) + let refreshed = try await fetchWorkspaces(client: client) + guard !Task.isCancelled else { return } + setWorkspaces(refreshed) } catch { // Keep the stale list; the event loop's stream death drives the // visible reconnect state. @@ -227,8 +261,11 @@ public final class HiveRemoteMacSession { await candidate.disconnect() return } + renderGridRouter?.stop() + renderGridRouter = nil if let previous = client { await previous.disconnect() } client = candidate + renderGridRouter = HiveRemoteRenderGridRouter(client: candidate) setWorkspaces(workspaces) phase = .connected startEventLoop(client: candidate) @@ -303,7 +340,7 @@ public final class HiveRemoteMacSession { // torn-down transport, which is the recovery path after a blip. let subscribe = try MobileCoreRPCClient.requestData( method: "mobile.events.subscribe", - params: ["topics": ["workspace.updated"]] + params: ["topics": ["workspace.updated", "terminal.render_grid"]] ) _ = try await client.sendRequest(subscribe) consecutiveFailures = 0 diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift new file mode 100644 index 000000000000..01ed5a2fff58 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift @@ -0,0 +1,105 @@ +public import CMUXMobileCore +public import CmuxMobileRPC +import Foundation + +/// Multiplexes one client's render-grid event stream by terminal surface. +/// +/// A remote Mac can have many terminals open at once, but the RPC transport +/// carries every `terminal.render_grid` event on one topic. Keeping one +/// underlying listener and routing each decoded frame to only its target +/// surface avoids decoding the same event once per mounted terminal. +@MainActor +public final class HiveRemoteRenderGridRouter { + private struct Listener { + let surfaceID: String + let continuation: AsyncStream.Continuation + } + + private let client: MobileCoreRPCClient + private var listeners: [UUID: Listener] = [:] + private var sourceTask: Task? + private var sourceGeneration = 0 + + /// Creates a router over one connected (or reconnecting) RPC client. + /// - Parameter client: The client's shared event transport. + public init(client: MobileCoreRPCClient) { + self.client = client + } + + /// Returns a stream containing only frames for `surfaceID`. + /// + /// The first listener starts one shared underlying RPC listener. When the + /// transport stream ends, all listeners finish and their terminal sessions + /// can request fresh streams as part of their normal recovery loop. + public func stream(for surfaceID: String) -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + listeners[id] = Listener(surfaceID: surfaceID, continuation: continuation) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.removeListener(id: id) + } + } + startSourceIfNeeded() + return stream + } + + /// Stop the shared listener and finish every surface stream. + public func stop() { + sourceGeneration &+= 1 + sourceTask?.cancel() + sourceTask = nil + finishListeners() + } + + private func startSourceIfNeeded() { + guard sourceTask == nil, !listeners.isEmpty else { return } + sourceGeneration &+= 1 + let generation = sourceGeneration + let client = self.client + sourceTask = Task { [weak self] in + let source = await client.subscribe(to: ["terminal.render_grid"]) + for await envelope in source { + guard !Task.isCancelled, + let payload = envelope.payloadJSON else { continue } + let frame = await Task.detached(priority: .userInitiated) { + HiveRemoteRenderGridDecoder().decodeFrame(payload) + }.value + guard let frame, !Task.isCancelled else { continue } + self?.yield(frame, generation: generation) + } + guard !Task.isCancelled else { return } + self?.finishSource(generation: generation) + } + } + + private func yield(_ frame: MobileTerminalRenderGridFrame, generation: Int) { + guard generation == sourceGeneration else { return } + for listener in listeners.values where listener.surfaceID == frame.surfaceID { + listener.continuation.yield(frame) + } + } + + private func finishSource(generation: Int) { + guard generation == sourceGeneration else { return } + sourceTask = nil + finishListeners() + } + + private func finishListeners() { + let continuations = listeners.values.map(\.continuation) + listeners.removeAll() + for continuation in continuations { + continuation.finish() + } + } + + private func removeListener(id: UUID) { + listeners.removeValue(forKey: id) + if listeners.isEmpty { + stop() + } + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift index 6ff26d3d1610..36d9d2e53748 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -47,6 +47,7 @@ public final class HiveRemoteTerminalSession { @ObservationIgnored public private(set) var lastFullFrameBytes: Data? @ObservationIgnored private let client: MobileCoreRPCClient + @ObservationIgnored private let renderGridRouter: HiveRemoteRenderGridRouter? @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void @ObservationIgnored private let renderGridDecoder: HiveRemoteRenderGridDecoder @ObservationIgnored private var attachTask: Task? @@ -73,12 +74,14 @@ public final class HiveRemoteTerminalSession { client: MobileCoreRPCClient, workspaceID: String, terminalID: String, - retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void + retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void, + renderGridRouter: HiveRemoteRenderGridRouter? = nil ) { self.client = client self.workspaceID = workspaceID self.terminalID = terminalID self.retryDelay = retryDelay + self.renderGridRouter = renderGridRouter self.renderGridDecoder = HiveRemoteRenderGridDecoder() } @@ -235,15 +238,13 @@ public final class HiveRemoteTerminalSession { private func runAttachLoop() async { var consecutiveFailures = 0 while !Task.isCancelled { - // Register the local listener BEFORE the replay so no frame - // emitted between the replay response and the subscription is lost. - let stream = await client.subscribe(to: ["terminal.render_grid"]) + let stream: AsyncStream do { - let subscribe = try MobileCoreRPCClient.requestData( - method: "mobile.events.subscribe", - params: ["topics": ["terminal.render_grid"]] - ) - _ = try await client.sendRequest(subscribe) + // Register the local listener BEFORE the replay so no frame + // emitted between the replay response and the subscription is + // lost. A Mac session supplies one shared router; direct test + // clients retain the legacy per-session subscription path. + stream = try await subscribeToFrames() try await requestReplay() guard !Task.isCancelled else { return } consecutiveFailures = 0 @@ -258,10 +259,7 @@ public final class HiveRemoteTerminalSession { await retryDelay(consecutiveFailures) continue } - for await envelope in stream { - guard let payload = envelope.payloadJSON, - let frame = await decodeFrameOffMain(payload), - frame.surfaceID == terminalID else { continue } + for await frame in stream { guard !Task.isCancelled else { return } enqueueFrame(frame) } @@ -273,6 +271,39 @@ public final class HiveRemoteTerminalSession { } } + private func subscribeToFrames() async throws -> AsyncStream { + if let renderGridRouter { + return renderGridRouter.stream(for: terminalID) + } + + let envelopes = await client.subscribe(to: ["terminal.render_grid"]) + let subscribe = try MobileCoreRPCClient.requestData( + method: "mobile.events.subscribe", + params: ["topics": ["terminal.render_grid"]] + ) + _ = try await client.sendRequest(subscribe) + + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + let decoder = renderGridDecoder + let terminalID = self.terminalID + let task = Task { + for await envelope in envelopes { + guard !Task.isCancelled, + let payload = envelope.payloadJSON else { continue } + let frame = await Task.detached(priority: .userInitiated) { + decoder.decodeFrame(payload) + }.value + guard let frame, frame.surfaceID == terminalID else { continue } + continuation.yield(frame) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + return stream + } + private func requestReplay() async throws { let request = try MobileCoreRPCClient.requestData( method: "mobile.terminal.replay", @@ -339,12 +370,4 @@ public final class HiveRemoteTerminalSession { frameBytesHandler?(bytes) } - /// Decode one `terminal.render_grid` event payload: either the wrapped - /// `{"render_grid": …}` form or the bare frame. - private func decodeFrameOffMain(_ payload: Data) async -> MobileTerminalRenderGridFrame? { - let decoder = renderGridDecoder - return await Task.detached(priority: .userInitiated) { - decoder.decodeFrame(payload) - }.value - } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift index c085cad2ca40..bd25b30f8edf 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift @@ -25,6 +25,15 @@ public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { /// - Throws: `CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable` /// when the route is not a tailnet-classified tailscale host. public func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport { + // A generic Stack-bearer request is never enough to authorize a raw + // Tailscale TCP channel. The viewer must gain an authenticated, + // cryptographically bound transport-admission mode before this factory + // can carry account credentials; until then fail closed. + guard request.authorizationMode == .transportAdmission else { + throw CmxNetworkByteTransportError.unsupportedAuthorizationMode( + request.authorizationMode + ) + } try makeVerifiedTransport(route: request.route) } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift index 8c85784b4313..b0a73b03b7c0 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift @@ -14,6 +14,13 @@ import Foundation /// Pure value type: the reduction is synchronous and deterministic, so frame /// application is unit-testable without any transport or view. public nonisolated struct HiveTerminalGridModel: Equatable, Sendable { + /// Upper bounds applied before allocating the row snapshot. A terminal + /// viewport larger than this is malformed for a viewer and must not be + /// allowed to turn a tiny hostile frame into an unbounded allocation. + public static let maximumColumns = 4_096 + public static let maximumRows = 16_384 + public static let maximumCellCount = 4_000_000 + /// One styled run of text within a row. public struct Span: Equatable, Sendable { /// Zero-based start column. @@ -82,6 +89,9 @@ public nonisolated struct HiveTerminalGridModel: Equatable, Sendable { _ frame: MobileTerminalRenderGridFrame, allowingFullReset: Bool = false ) -> Bool { + guard Self.hasSafeDimensions(columns: frame.columns, rows: frame.rows) else { + return false + } let stylesByID = Dictionary( frame.styles.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } @@ -155,4 +165,13 @@ public nonisolated struct HiveTerminalGridModel: Equatable, Sendable { } rows = newRows } + + private static func hasSafeDimensions(columns: Int, rows: Int) -> Bool { + guard columns > 0, rows > 0, + columns <= maximumColumns, + rows <= maximumRows else { + return false + } + return rows <= maximumCellCount / columns + } } diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift index 5603540a240c..19c0c5b3a57b 100644 --- a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift @@ -95,15 +95,13 @@ public struct HiveViewerRootView: View { private func ensureTerminalSession(for selection: HiveViewerSelection?) { guard let selection, terminalSessions[selection] == nil, - let client = session.client else { return } - let terminal = HiveRemoteTerminalSession( - client: client, + let terminal = session.makeTerminalSession( workspaceID: selection.workspaceID, terminalID: selection.terminalID, retryDelay: { @Sendable attempt in await HiveReconnectBackoff().delay(attempt: attempt) } - ) + ) else { return } terminalSessions[selection] = terminal } diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index d8bbf247cf8d..affa18564daa 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -24,9 +24,11 @@ final class HiveComputerMirrorController { private init() {} private final class DeviceMirror { + let deviceID: String var computerName: String = "" weak var tabManager: TabManager? var reconcileTask: Task? + var routeTask: Task? var windowCloseObserver: NSObjectProtocol? /// Local mirror workspace id per remote workspace id. var workspaceIdByRemoteID: [String: UUID] = [:] @@ -37,9 +39,21 @@ final class HiveComputerMirrorController { /// Remote terminal ids per remote workspace id (repaint scoping and /// dead-terminal pruning). var terminalIDsByRemoteWorkspaceID: [String: [String]] = [:] + + init(deviceID: String) { + self.deviceID = deviceID + } + } + + /// Mirror ownership is per computer *and* per main-window TabManager. + /// Two windows may scope to the same computer without stealing each + /// other's workspaces. + private struct MirrorKey: Hashable { + let deviceID: String + let tabManagerID: ObjectIdentifier } - private var mirrorsByDeviceID: [String: DeviceMirror] = [:] + private var mirrorsByKey: [MirrorKey: DeviceMirror] = [:] /// Last remote grid dimensions applied to one mirror surface (closure /// state; a class so the frame handler can mutate its capture). @@ -60,7 +74,7 @@ final class HiveComputerMirrorController { /// the selected workspace's terminals only (a device-wide repaint storms /// replays on every click). func workspaceSelected(_ workspaceId: UUID) { - for mirror in mirrorsByDeviceID.values { + for mirror in mirrorsByKey.values { guard let remoteWorkspaceID = mirror.workspaceIdByRemoteID .first(where: { $0.value == workspaceId })?.key else { continue } let terminalIDs = mirror.terminalIDsByRemoteWorkspaceID[remoteWorkspaceID] ?? [] @@ -83,9 +97,9 @@ final class HiveComputerMirrorController { /// The device a mirror workspace belongs to, or `nil` for local /// workspaces. Drives the sidebar's computer scope filter. func deviceID(forWorkspace workspaceId: UUID) -> String? { - for (deviceID, mirror) in mirrorsByDeviceID + for mirror in mirrorsByKey.values where mirror.workspaceIdByRemoteID.values.contains(workspaceId) { - return deviceID + return mirror.deviceID } return nil } @@ -148,25 +162,31 @@ final class HiveComputerMirrorController { /// host's live topology. @discardableResult func attach(deviceID: String, into tabManager: TabManager) async -> UUID? { - if let existing = mirrorsByDeviceID[deviceID] { + let key = MirrorKey(deviceID: deviceID, tabManagerID: ObjectIdentifier(tabManager)) + if let existing = mirrorsByKey[key] { if let firstId = existing.workspaceIdByRemoteID.values.first, let workspace = tabManager.workspacesById[firstId] { tabManager.selectWorkspace(workspace) return firstId } - await teardownMirror(deviceID: deviceID, mirror: existing) - mirrorsByDeviceID.removeValue(forKey: deviceID) + mirrorsByKey.removeValue(forKey: key) + let shouldDiscard = !mirrorsByKey.keys.contains { $0.deviceID == deviceID } + await teardownMirror( + deviceID: deviceID, + mirror: existing, + discardEmbeddedSession: shouldDiscard + ) } guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID) else { cmuxDebugLog("hive.mirror.attach.noSession device=\(deviceID.prefix(8))") return nil } - let mirror = DeviceMirror() + let mirror = DeviceMirror(deviceID: deviceID) mirror.tabManager = tabManager mirror.computerName = HiveComputersService.shared.directory?.computers .first(where: { $0.deviceID == deviceID })?.displayName ?? session.displayName - mirrorsByDeviceID[deviceID] = mirror + mirrorsByKey[key] = mirror if let window = tabManager.window { mirror.windowCloseObserver = NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, @@ -186,6 +206,42 @@ final class HiveComputerMirrorController { self.reconcile(remote: workspaces, mirror: mirror, session: session) } } + if let directory = HiveComputersService.shared.directory { + mirror.routeTask = Task { @MainActor [weak self, weak mirror, weak tabManager] in + for await computers in directory.updates() { + guard let self, let mirror, let tabManager else { return } + let key = MirrorKey( + deviceID: deviceID, + tabManagerID: ObjectIdentifier(tabManager) + ) + guard self.mirrorsByKey[key] === mirror else { return } + guard let computer = computers.first(where: { $0.deviceID == deviceID }) else { + continue + } + guard computer.isPaired, let best = computer.bestPairingRoutes else { + await self.detach(deviceID: deviceID, from: tabManager) + return + } + guard best.routes != session.routes + || best.instanceTag != session.expectedInstanceTag + || computer.isRegistryBacked != session.requiresHostIdentity else { + continue + } + // Ask the service to replace the immutable session before + // rebuilding this window's mirrors on the fresh routes. + _ = await HiveComputersService.shared.embeddedSession(deviceID: deviceID) + guard self.mirrorsByKey[key] === mirror else { return } + self.mirrorsByKey.removeValue(forKey: key) + await self.teardownMirror( + deviceID: deviceID, + mirror: mirror, + discardEmbeddedSession: false + ) + _ = await self.attach(deviceID: deviceID, into: tabManager) + return + } + } + } // Wait briefly for the first non-empty list so the caller can select // a mirror workspace; reconciliation keeps running either way. @@ -208,29 +264,51 @@ final class HiveComputerMirrorController { /// Detaches a computer's mirrors: stops the streams and closes the /// mirror workspaces. - func detach(deviceID: String, from _: TabManager) async { - guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } - await teardownMirror(deviceID: deviceID, mirror: mirror) + func detach(deviceID: String, from tabManager: TabManager) async { + let key = MirrorKey(deviceID: deviceID, tabManagerID: ObjectIdentifier(tabManager)) + guard let mirror = mirrorsByKey.removeValue(forKey: key) else { return } + let shouldDiscard = !mirrorsByKey.keys.contains { $0.deviceID == deviceID } + await teardownMirror(deviceID: deviceID, mirror: mirror, discardEmbeddedSession: shouldDiscard) } /// Tear down every embedded mirror, used when account auth ends. func detachAll() async { - let mirrors = mirrorsByDeviceID - mirrorsByDeviceID.removeAll() - for (deviceID, mirror) in mirrors { - await teardownMirror(deviceID: deviceID, mirror: mirror) + let mirrors = Array(mirrorsByKey.values) + mirrorsByKey.removeAll() + var deviceIDs = Set() + for mirror in mirrors { + deviceIDs.insert(mirror.deviceID) + await teardownMirror( + deviceID: mirror.deviceID, + mirror: mirror, + discardEmbeddedSession: false + ) + } + for deviceID in deviceIDs { + await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) } } /// Tear down one device's mirror regardless of which window owns it. func detach(deviceID: String) async { - guard let mirror = mirrorsByDeviceID.removeValue(forKey: deviceID) else { return } - await teardownMirror(deviceID: deviceID, mirror: mirror) + let keys = mirrorsByKey.keys.filter { $0.deviceID == deviceID } + guard !keys.isEmpty else { return } + let mirrors = keys.compactMap { mirrorsByKey.removeValue(forKey: $0) } + for mirror in mirrors { + await teardownMirror(deviceID: deviceID, mirror: mirror, discardEmbeddedSession: false) + } + await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) } - private func teardownMirror(deviceID: String, mirror: DeviceMirror) async { + private func teardownMirror( + deviceID: String, + mirror: DeviceMirror, + discardEmbeddedSession: Bool + ) async { mirror.reconcileTask?.cancel() mirror.reconcileTask = nil + mirror.routeTask?.cancel() + mirror.routeTask = nil if let observer = mirror.windowCloseObserver { NotificationCenter.default.removeObserver(observer) mirror.windowCloseObserver = nil @@ -247,7 +325,9 @@ final class HiveComputerMirrorController { mirror.workspaceIdByRemoteID.removeAll() mirror.panelIdByRemoteTerminalID.removeAll() mirror.terminalIDsByRemoteWorkspaceID.removeAll() - await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) + if discardEmbeddedSession { + await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) + } } // MARK: - Reconcile @@ -257,7 +337,7 @@ final class HiveComputerMirrorController { mirror: DeviceMirror, session: HiveRemoteMacSession ) { - guard let tabManager = mirror.tabManager, let client = session.client else { return } + guard let tabManager = mirror.tabManager, session.client != nil else { return } let remoteIDs = Set(workspaces.map(\.id)) // Remove mirrors whose remote workspace vanished (closed on host, or @@ -310,7 +390,7 @@ final class HiveComputerMirrorController { workspace.updateRemoteTmuxTabTitle(panelId: panelID, title: terminal.title) } } - addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, client: client) + addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, session: session) continue } let title = localizedWorkspaceTitle( @@ -330,7 +410,7 @@ final class HiveComputerMirrorController { workspace.isRemoteTmuxMirror = true mirror.workspaceIdByRemoteID[remote.id] = workspace.id let defaultPanelIds = Array(workspace.panels.keys) - addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, client: client) + addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, session: session) for panelId in defaultPanelIds where workspace.panels[panelId] != nil { _ = workspace.removeRemoteTmuxDisplayPane(panelId) } @@ -347,18 +427,17 @@ final class HiveComputerMirrorController { remote: HiveRemoteWorkspace, workspace: Workspace, mirror: DeviceMirror, - client: MobileCoreRPCClient + session: HiveRemoteMacSession ) { for (index, terminal) in remote.terminals.enumerated() where mirror.terminalsByRemoteID[terminal.id] == nil { - let terminalSession = HiveRemoteTerminalSession( - client: client, + guard let terminalSession = session.makeTerminalSession( workspaceID: remote.id, terminalID: terminal.id, retryDelay: { @Sendable attempt in await HiveReconnectBackoff().delay(attempt: attempt) } - ) + ) else { continue } let panelBox = HiveMirrorPanelBox() guard let panel = workspace.addRemoteTmuxDisplayPane( remotePaneId: index, diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index df136c74a10b..0c43de824566 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -84,13 +84,12 @@ final class HiveComputersService { retryDelay: { @Sendable attempt in await HiveReconnectBackoff().delay(attempt: attempt) }, - // Only registry-backed rows have authenticated device provenance - // for the encrypted Tailscale trust mode. Manual/pasted links keep - // the safe loopback-only policy, so they can never receive a Stack - // bearer token over an arbitrary tailnet endpoint. - stackAuthChannelTrust: computer.isRegistryBacked - ? .loopbackAndTailscaleTunnel - : .loopbackOnly, + // A registry row is rendezvous metadata, not cryptographic proof + // that the listener at its advertised Tailscale endpoint is the + // named Mac. Keep the bearer on loopback until a pinned + // device/endpoint binding is available; `mobile.host.status` may + // still probe the endpoint, but it cannot disclose the token. + stackAuthChannelTrust: .loopbackOnly, expectedInstanceTag: best.instanceTag, requiresHostIdentity: computer.isRegistryBacked ) @@ -106,9 +105,14 @@ final class HiveComputersService { /// pairing record or auth is not configured. func embeddedSession(deviceID: String) async -> HiveRemoteMacSession? { if let existing = embeddedSessions[deviceID] { - if let computer = directory?.computers.first(where: { $0.deviceID == deviceID }), - let best = computer.bestPairingRoutes, - best.routes != existing.routes { + let computer = directory?.computers.first(where: { $0.deviceID == deviceID }) + let best = computer?.bestPairingRoutes + let bindingChanged = computer == nil + || computer?.isPaired != true + || best?.routes != existing.routes + || best?.instanceTag != existing.expectedInstanceTag + || computer?.isRegistryBacked != existing.requiresHostIdentity + if bindingChanged { embeddedSessions.removeValue(forKey: deviceID) await existing.disconnect() } else { diff --git a/Sources/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift index 3f36434744b7..39821ffb9b76 100644 --- a/Sources/Hive/HiveViewerWindowController.swift +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -19,9 +19,15 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { /// terminal tab in the main window behind it. static let windowIdentifier = "cmux.hiveViewerWindow" - private struct OpenViewer { + private final class OpenViewer { let window: NSWindow - let session: HiveRemoteMacSession + var session: HiveRemoteMacSession + var routeTask: Task? + + init(window: NSWindow, session: HiveRemoteMacSession) { + self.window = window + self.session = session + } } private var viewersByDeviceID: [String: OpenViewer] = [:] @@ -54,9 +60,9 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { return } let appearanceMode = UserDefaults.standard.string(forKey: AppearanceSettings.appearanceModeKey) - let root = HiveViewerRootView(session: session) - .cmuxAppearanceColorScheme(appearanceMode) - let hostingController = NSHostingController(rootView: root) + let hostingController = NSHostingController( + rootView: makeRootView(session: session, appearanceMode: appearanceMode) + ) let window = NSWindow(contentViewController: hostingController) window.title = session.displayName window.identifier = NSUserInterfaceItemIdentifier(Self.windowIdentifier) @@ -66,14 +72,65 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { window.center() window.isReleasedWhenClosed = false window.delegate = self - viewersByDeviceID[deviceID] = OpenViewer(window: window, session: session) + let entry = OpenViewer(window: window, session: session) + viewersByDeviceID[deviceID] = entry + entry.routeTask = watchRoutes(deviceID: deviceID, session: session, appearanceMode: appearanceMode) window.makeKeyAndOrderFront(nil) } + private func makeRootView( + session: HiveRemoteMacSession, + appearanceMode: String? + ) -> some View { + HiveViewerRootView(session: session) + .cmuxAppearanceColorScheme(appearanceMode) + } + + private func watchRoutes( + deviceID: String, + session: HiveRemoteMacSession, + appearanceMode: String? + ) -> Task { + guard let directory = HiveComputersService.shared.directory else { + return Task {} + } + return Task { @MainActor [weak self, weak session] in + for await computers in directory.updates() { + guard let self, let session, + let entry = self.viewersByDeviceID[deviceID], + entry.session === session else { return } + guard let computer = computers.first(where: { $0.deviceID == deviceID }), + computer.isPaired, + let best = computer.bestPairingRoutes else { + continue + } + guard best.routes != session.routes + || best.instanceTag != session.expectedInstanceTag + || computer.isRegistryBacked != session.requiresHostIdentity else { + continue + } + guard let replacement = await HiveComputersService.shared.makeViewerSession(deviceID: deviceID) else { + continue + } + replacement.connect() + entry.session = replacement + entry.window.contentViewController = NSHostingController( + rootView: self.makeRootView( + session: replacement, + appearanceMode: appearanceMode + ) + ) + await session.disconnect() + return + } + } + } + func windowWillClose(_ notification: Notification) { guard let window = notification.object as? NSWindow, let entry = viewersByDeviceID.first(where: { $0.value.window === window }) else { return } viewersByDeviceID.removeValue(forKey: entry.key) + entry.value.routeTask?.cancel() let session = entry.value.session Task { @MainActor in await session.disconnect() @@ -85,6 +142,7 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { let entries = viewersByDeviceID viewersByDeviceID.removeAll() for entry in entries.values { + entry.routeTask?.cancel() entry.window.close() await entry.session.disconnect() } From 2f93d4586bc2d888d783502f96b328f2db4559b9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 19:55:33 -0700 Subject: [PATCH 42/97] Hive directory: cover unpair sign-out race --- .../HiveComputerDirectoryTests.swift | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift index 72be277ad03a..8c0841a1ca86 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -19,6 +19,22 @@ private struct ScriptedRegistry: DeviceRegistryRefreshing { } } +private actor ScopeBox { + var scope: HiveAccountScope + + init(_ scope: HiveAccountScope) { + self.scope = scope + } + + func read() -> HiveAccountScope { + scope + } + + func set(_ scope: HiveAccountScope) { + self.scope = scope + } +} + @MainActor private func makeTempStore() throws -> (store: MobilePairedMacStore, cleanup: () -> Void) { let directory = FileManager.default.temporaryDirectory @@ -211,6 +227,39 @@ private func makeDirectory( #expect(row.isPaired == false) } + @MainActor + @Test func unpairAfterSignOutDoesNotDeleteScopedRecords() async throws { + let (store, cleanup) = try makeTempStore() + defer { cleanup() } + let route = try tailscaleRoute() + let device = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [RegistryAppInstance(tag: "stable", routes: [route], lastSeenAt: Date(timeIntervalSince1970: 900))] + ) + let scopeBox = ScopeBox(HiveAccountScope(stackUserID: "user-1", teamID: "team-1")) + let directory = HiveComputerDirectory( + registry: ScriptedRegistry(outcome: .ok([device])), + pairedStore: store, + presence: nil, + ownDeviceID: "self-device", + scopeProvider: { await scopeBox.read() }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + now: { Date(timeIntervalSince1970: 1_000) }, + presenceRetryDelay: { _ in } + ) + await directory.refresh() + #expect(await directory.pair(deviceID: "registry-mac") == .paired(deviceID: "registry-mac")) + + await scopeBox.set(HiveAccountScope(stackUserID: nil, teamID: nil)) + directory.clearForSignOut() + #expect(await directory.unpair(deviceID: "registry-mac") == false) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.count == 1) + } + @MainActor @Test func transientRegistryFailureKeepsPreviousRows() async throws { let (store, cleanup) = try makeTempStore() From 03e35f14aad5a8356e78a66d6a3f68a94612d5fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 19:56:52 -0700 Subject: [PATCH 43/97] Hive viewer: preserve scope across safe teardown --- .../Sources/CmuxHive/HiveComputerDirectory.swift | 10 ++++++++-- Sources/Hive/HiveComputerMirrorController.swift | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index df54ccc20cb3..7919342515ed 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -380,7 +380,12 @@ public final class HiveComputerDirectory { @discardableResult public func unpair(deviceID: String) async -> Bool { let scope = await scopeProvider() - guard loadedScope == scope else { return false } + let generation = scopeGeneration + guard scope.stackUserID != nil, + loadedScope == scope, + await isCurrentScope(scope, generation: generation) else { + return false + } do { try await pairedStore.remove( macDeviceID: deviceID, @@ -390,8 +395,9 @@ public final class HiveComputerDirectory { } catch { return false } + guard await isCurrentScope(scope, generation: generation) else { return false } await reloadPairedRecords(scope: scope) - guard await isCurrentScope(scope, generation: scopeGeneration) else { return false } + guard await isCurrentScope(scope, generation: generation) else { return false } rebuild() return true } diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index affa18564daa..360a9da287d3 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -315,7 +315,14 @@ final class HiveComputerMirrorController { } for (_, terminal) in mirror.terminalsByRemoteID { terminal.detach() } if let tabManager = mirror.tabManager { - tabManager.hiveSidebarScopeModel.scope = .thisMac + let hasSurvivingMirror = mirrorsByKey.values.contains { + $0 !== mirror && $0.tabManager === tabManager + } + if !hasSurvivingMirror, + case .device(let scopedDeviceID) = tabManager.hiveSidebarScopeModel.scope, + scopedDeviceID == deviceID { + tabManager.hiveSidebarScopeModel.scope = .thisMac + } for (_, workspaceId) in mirror.workspaceIdByRemoteID { guard let workspace = tabManager.workspacesById[workspaceId] else { continue } tabManager.closeWorkspace(workspace) From 10ba5917e9f9228063cc75285dbf3931d366dbf4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 20:00:21 -0700 Subject: [PATCH 44/97] Hive viewer: return the guarded Tailscale transport --- .../Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift index bd25b30f8edf..351fd747e035 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift @@ -34,7 +34,7 @@ public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { request.authorizationMode ) } - try makeVerifiedTransport(route: request.route) + return try makeVerifiedTransport(route: request.route) } private func makeVerifiedTransport(route: CmxAttachRoute) throws -> any CmxByteTransport { From 1f3fee401e840604a44f27d636bd9a421abcd7e8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 21:19:25 -0700 Subject: [PATCH 45/97] Fix Hive viewer auth and lifecycle recovery --- .../CmuxHive/HiveComputerDirectory.swift | 9 +++++++- .../HiveTailscaleByteTransportFactory.swift | 13 +++++++----- ...veTailscaleByteTransportFactoryTests.swift | 14 ++++++++++++- Sources/Hive/HiveComputersService.swift | 13 ++++++------ Sources/Hive/HiveViewerWindowController.swift | 21 ++++++++++++++----- Sources/HostSettingsActions+Computers.swift | 3 ++- ...ticalTabsSidebar+EmptyAreasAndFooter.swift | 5 +++++ 7 files changed, 59 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index 7919342515ed..d205ecfbc4e7 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -300,7 +300,14 @@ public final class HiveComputerDirectory { let claimTime = now() let matches = registryDevices.flatMap { device in device.instances - .filter { $0.hasRoutes && $0.activePairingCode(now: claimTime)?.code == code } + // Pairing-code claims use the registry snapshot directly. Keep + // the label-bearing `RegistryAppInstance` at this boundary; + // the settings row model intentionally omits labels because + // they are rendezvous metadata, not UI state. + .filter { + !$0.routes.isEmpty + && CmxPairingCode.active(in: $0.labels, now: claimTime)?.code == code + } .map { (device: device, instance: $0) } } // Exactly one live match may claim; a 6-digit collision inside one diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift index 351fd747e035..de3b71bfe92b 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift @@ -25,11 +25,14 @@ public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { /// - Throws: `CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable` /// when the route is not a tailnet-classified tailscale host. public func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport { - // A generic Stack-bearer request is never enough to authorize a raw - // Tailscale TCP channel. The viewer must gain an authenticated, - // cryptographically bound transport-admission mode before this factory - // can carry account credentials; until then fail closed. - guard request.authorizationMode == .transportAdmission else { + // The Mac viewer's registry-backed routes are admitted by the + // tailnet address check below and by MobileShellRouteAuthPolicy's + // `.loopbackAndTailscaleTunnel` trust. MobileCoreRPCClient uses the + // `.stackBearer` intent for this existing RPC lane; keep accepting the + // transport-admission intent too for callers that already completed an + // out-of-band handshake. + guard request.authorizationMode == .stackBearer + || request.authorizationMode == .transportAdmission else { throw CmxNetworkByteTransportError.unsupportedAuthorizationMode( request.authorizationMode ) diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift index e1f9e6ed5217..1d29e200b192 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift @@ -31,13 +31,25 @@ struct HiveTailscaleByteTransportFactoryTests { } } - @Test func rejectsGenericStackBearerRequestsUntilPeerAdmissionIsProven() throws { + @Test func acceptsStackBearerRequestsForVerifiedTailnetHosts() throws { let route = try route(kind: .tailscale, host: "100.65.181.35") let request = CmxByteTransportRequest( route: route, expectedPeerDeviceID: "mac-b", authorizationMode: .stackBearer ) + #expect(throws: Never.self) { + _ = try HiveTailscaleByteTransportFactory().makeTransport(for: request) + } + } + + @Test func rejectsStackBearerRequestsForUnverifiedHosts() throws { + let route = try route(kind: .tailscale, host: "192.168.86.21") + let request = CmxByteTransportRequest( + route: route, + expectedPeerDeviceID: "mac-b", + authorizationMode: .stackBearer + ) #expect(throws: (any Error).self) { _ = try HiveTailscaleByteTransportFactory().makeTransport(for: request) } diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index 0c43de824566..eea249f05f1c 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -84,12 +84,13 @@ final class HiveComputersService { retryDelay: { @Sendable attempt in await HiveReconnectBackoff().delay(attempt: attempt) }, - // A registry row is rendezvous metadata, not cryptographic proof - // that the listener at its advertised Tailscale endpoint is the - // named Mac. Keep the bearer on loopback until a pinned - // device/endpoint binding is available; `mobile.host.status` may - // still probe the endpoint, but it cannot disclose the token. - stackAuthChannelTrust: .loopbackOnly, + // Registry-backed routes are admitted by the verified Tailscale + // transport factory and then bound to the expected host identity + // via `mobile.host.status`. Pasted/manual links remain + // loopback-only until they gain that registry provenance. + stackAuthChannelTrust: computer.isRegistryBacked + ? .loopbackAndTailscaleTunnel + : .loopbackOnly, expectedInstanceTag: best.instanceTag, requiresHostIdentity: computer.isRegistryBacked ) diff --git a/Sources/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift index 39821ffb9b76..0010595d9259 100644 --- a/Sources/Hive/HiveViewerWindowController.swift +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -94,11 +94,12 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { guard let directory = HiveComputersService.shared.directory else { return Task {} } - return Task { @MainActor [weak self, weak session] in + return Task { @MainActor [weak self] in + var currentSession = session for await computers in directory.updates() { - guard let self, let session, + guard let self, let entry = self.viewersByDeviceID[deviceID], - entry.session === session else { return } + entry.session === currentSession else { return } guard let computer = computers.first(where: { $0.deviceID == deviceID }), computer.isPaired, let best = computer.bestPairingRoutes else { @@ -120,12 +121,22 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { appearanceMode: appearanceMode ) ) - await session.disconnect() - return + await currentSession.disconnect() + currentSession = replacement } } } + /// Close a standalone viewer and revoke its remote session, used when a + /// paired computer is explicitly forgotten from Settings. + func close(deviceID: String) async { + guard let entry = viewersByDeviceID.removeValue(forKey: deviceID) else { return } + entry.routeTask?.cancel() + entry.routeTask = nil + entry.window.close() + await entry.session.disconnect() + } + func windowWillClose(_ notification: Notification) { guard let window = notification.object as? NSWindow, let entry = viewersByDeviceID.first(where: { $0.value.window === window }) else { return } diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift index b8691cd3ebb6..9aff9355e42d 100644 --- a/Sources/HostSettingsActions+Computers.swift +++ b/Sources/HostSettingsActions+Computers.swift @@ -59,8 +59,9 @@ extension HostSettingsActions { func unpairComputer(deviceID: String) async { guard let directory = computersDirectory else { return } - await directory.unpair(deviceID: deviceID) + guard await directory.unpair(deviceID: deviceID) else { return } await HiveComputerMirrorController.shared.detach(deviceID: deviceID) + await HiveViewerWindowController.shared.close(deviceID: deviceID) } func openComputerViewer(deviceID: String) { diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index f1ef0c96a734..04a23e972400 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -141,6 +141,11 @@ struct HiveSidebarScopePicker: View { private func observeComputers() async { guard let directory = HiveComputersService.shared.directory else { return } + // The directory is composed before any particular settings pane is + // mounted. Refresh here so sidebar presentation discovers persisted + // pairings on a fresh launch instead of waiting for Settings › + // Computers to be opened first. + await directory.refresh() for await merged in directory.updates() { let snapshot = merged .filter { $0.isPaired && !$0.isThisComputer } From 2ff86675163c23a6ef1f6649a5aaf63335f62151 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 21:35:18 -0700 Subject: [PATCH 46/97] Move pairing code formatter helpers to file scope --- .../CMUXMobileCore/CmxPairingCode.swift | 45 ++++++++++--------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift index f01c1913e20a..1a6dfc1d4bb1 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift @@ -59,7 +59,7 @@ public struct CmxPairingCode: Equatable, Sendable { public var instanceLabels: [String: String] { [ Self.codeLabelKey: code, - Self.expiresAtLabelKey: Self.iso8601.string(from: expiresAt), + Self.expiresAtLabelKey: pairingCodeISO8601Formatter().string(from: expiresAt), ] } @@ -77,7 +77,7 @@ public struct CmxPairingCode: Equatable, Sendable { let code = labels[codeLabelKey]?.trimmingCharacters(in: .whitespacesAndNewlines), !code.isEmpty, let rawExpiry = labels[expiresAtLabelKey], - let expiresAt = parseISO8601(rawExpiry), + let expiresAt = parsePairingCodeISO8601(rawExpiry), expiresAt > now else { return nil } return CmxPairingCode(code: code, expiresAt: expiresAt) @@ -92,25 +92,26 @@ public struct CmxPairingCode: Equatable, Sendable { return digits.count == 6 ? digits : nil } - /// Encoder for the expiry label; fractional seconds match the registry's - /// own `toISOString()` timestamps. - private static var iso8601: ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - } +} - /// Lenient ISO 8601 parse (fractional and whole-second forms). Formatters - /// are created per call so the type stays `Sendable`-clean; this runs only - /// on label reads, never in a hot path. - private static func parseISO8601(_ raw: String) -> Date? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = fractional.date(from: trimmed) { return date } - let whole = ISO8601DateFormatter() - whole.formatOptions = [.withInternetDateTime] - return whole.date(from: trimmed) - } +/// Encoder for the expiry label; fractional seconds match the registry's own +/// `toISOString()` timestamps. +private func pairingCodeISO8601Formatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +} + +/// Lenient ISO 8601 parse (fractional and whole-second forms). Formatters are +/// created per call so the surrounding value remains `Sendable`-clean; this +/// runs only on label reads, never in a hot path. +private func parsePairingCodeISO8601(_ raw: String) -> Date? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: trimmed) { return date } + let whole = ISO8601DateFormatter() + whole.formatOptions = [.withInternetDateTime] + return whole.date(from: trimmed) } From 8b058f0aa8530887c14b4c30b41cf59932bde4c7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 22:05:49 -0700 Subject: [PATCH 47/97] Test Tailscale peer admission boundaries --- .../CmxTailscaleStatusPeerResolverTests.swift | 21 +++++++++++++++++++ ...veTailscaleByteTransportFactoryTests.swift | 16 +++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift index 8132d967a838..83516b3438a1 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift @@ -184,6 +184,27 @@ import Testing } } + @Test func verifiesOnlyRemoteAddressesPresentInTheCurrentPeerMap() throws { + let status = try statusJSON( + local: peer( + id: "self-node", + dnsName: "this-mac.tailnet.ts.net.", + addresses: ["100.70.1.5"] + ), + peers: [ + peer( + id: "node-1", + dnsName: "work-mac.tailnet.ts.net.", + addresses: ["100.71.210.41"] + ), + ] + ) + + #expect(try resolver.containsRemotePeerAddress("100.71.210.41", statusJSON: status)) + #expect(!(try resolver.containsRemotePeerAddress("100.72.1.9", statusJSON: status))) + #expect(!(try resolver.containsRemotePeerAddress("100.70.1.5", statusJSON: status))) + } + private func statusJSON( backendState: String = "Running", local: [String: Any]? = nil, diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift index 1d29e200b192..85cf8cf7509e 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift @@ -39,7 +39,9 @@ struct HiveTailscaleByteTransportFactoryTests { authorizationMode: .stackBearer ) #expect(throws: Never.self) { - _ = try HiveTailscaleByteTransportFactory().makeTransport(for: request) + _ = try HiveTailscaleByteTransportFactory( + admittedHosts: ["100.65.181.35"] + ).makeTransport(for: request) } } @@ -54,4 +56,16 @@ struct HiveTailscaleByteTransportFactoryTests { _ = try HiveTailscaleByteTransportFactory().makeTransport(for: request) } } + + @Test func rejectsValidTailnetHostsWithoutControlPlaneAdmission() throws { + let route = try route(kind: .tailscale, host: "100.65.181.35") + let request = CmxByteTransportRequest( + route: route, + expectedPeerDeviceID: "mac-b", + authorizationMode: .stackBearer + ) + #expect(throws: (any Error).self) { + _ = try HiveTailscaleByteTransportFactory().makeTransport(for: request) + } + } } From 941f6aa2fababff153bb2682e279aa8e8ece4a9e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 22:10:34 -0700 Subject: [PATCH 48/97] Bind Hive viewers to verified Tailscale peers --- .../CmxTailscaleStatusPeerResolver.swift | 68 +++++++++++++++ .../CmuxHive/HiveRemoteMacSession.swift | 60 +++++++++---- .../Sources/CmuxHive/HiveSyncRuntime.swift | 8 +- .../HiveTailscaleByteTransportFactory.swift | 22 +++-- .../Sections/ComputersSection.swift | 10 ++- .../Hive/HiveComputerMirrorController.swift | 31 +++++-- Sources/Hive/HiveComputersService.swift | 87 ++++++++++++++++--- .../Hive/HiveTailscaleRouteAdmission.swift | 87 +++++++++++++++++++ Sources/Hive/HiveViewerWindowController.swift | 20 +++-- 9 files changed, 332 insertions(+), 61 deletions(-) create mode 100644 Sources/Hive/HiveTailscaleRouteAdmission.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift index d41e4f2c99f7..810decff9a16 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxTailscaleStatusPeerResolver.swift @@ -99,6 +99,74 @@ public struct CmxTailscaleStatusPeerResolver: Sendable { ) } + /// Verifies that a numeric Tailscale address belongs to a remote peer in + /// one authenticated local `tailscale status --json` snapshot. + /// + /// This is stronger than checking the CGNAT/ULA range alone: the address + /// must be present in the daemon's current peer map, and the local `Self` + /// record is never accepted. Callers can use the returned proof to bind a + /// bearer-carrying route to the control-plane snapshot that admitted it. + /// + /// - Parameters: + /// - rawAddress: Numeric Tailscale IPv4 or IPv6 peer address. + /// - statusJSON: Authenticated local daemon output. + /// - Returns: `true` when a remote peer owns the address in the snapshot. + /// - Throws: ``CmxTailscaleStatusPeerResolutionError`` when the snapshot + /// or address is malformed. + public func containsRemotePeerAddress( + _ rawAddress: String, + statusJSON: Data + ) throws -> Bool { + guard let requested = CmxTailscalePeerAddress(rawAddress) else { + throw CmxTailscaleStatusPeerResolutionError.invalidPeerAddress + } + guard !statusJSON.isEmpty, + statusJSON.count <= Self.maximumStatusBytes, + let root = try? JSONSerialization.jsonObject(with: statusJSON) as? [String: Any] + else { + throw CmxTailscaleStatusPeerResolutionError.malformedStatus + } + guard root["BackendState"] as? String == "Running" else { + throw CmxTailscaleStatusPeerResolutionError.statusNotRunning + } + guard let rawPeers = root["Peer"] else { + return false + } + guard let peers = rawPeers as? [String: Any] else { + throw CmxTailscaleStatusPeerResolutionError.malformedStatus + } + guard peers.count <= Self.maximumPeerRecords else { + throw CmxTailscaleStatusPeerResolutionError.malformedStatus + } + if let local = root["Self"] as? [String: Any], + let rawLocalAddresses = local["TailscaleIPs"] as? [Any] { + for rawLocalAddress in rawLocalAddresses { + guard let rawLocalAddress = rawLocalAddress as? String, + let localAddress = CmxTailscalePeerAddress(rawLocalAddress) + else { + throw CmxTailscaleStatusPeerResolutionError.invalidPeerAddress + } + if localAddress == requested { return false } + } + } + for value in peers.values { + guard let peer = value as? [String: Any] else { continue } + guard let rawAddresses = peer["TailscaleIPs"] as? [Any], + !rawAddresses.isEmpty else { continue } + for rawPeerAddress in rawAddresses { + guard let rawPeerAddress = rawPeerAddress as? String, + let peerAddress = CmxTailscalePeerAddress(rawPeerAddress) + else { + throw CmxTailscaleStatusPeerResolutionError.invalidPeerAddress + } + if peerAddress == requested { + return true + } + } + } + return false + } + private func normalizedMagicDNSName(_ rawName: String) -> String? { guard let name = normalizedDNSName(rawName), name.hasSuffix(".ts.net") else { return nil diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift index 895a8e491b96..9b302e34b4a1 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -38,6 +38,10 @@ public final class HiveRemoteMacSession { /// The currently selected route set; a changed registry/presence snapshot /// causes the app service to replace this session before reconnecting. public let routes: [CmxAttachRoute] + /// The registry route snapshot from which ``routes`` was admitted. Used to + /// detect binding changes without re-running the local Tailscale proof on + /// every presence update. + public let sourceRoutes: [CmxAttachRoute] /// The registry instance tag this session was bound to, when available. public let expectedInstanceTag: String? /// Whether this session requires the authenticated host-identity gate. @@ -60,7 +64,7 @@ public final class HiveRemoteMacSession { @ObservationIgnored public private(set) var renderGridRouter: HiveRemoteRenderGridRouter? @ObservationIgnored private var eventTask: Task? @ObservationIgnored private var connectTask: Task? - @ObservationIgnored private var workspaceRefreshTask: Task? + @ObservationIgnored private var workspaceRefreshTask: Task? @ObservationIgnored private var workspaceRefreshPending = false @ObservationIgnored private var workspaceListeners: [UUID: AsyncStream<[HiveRemoteWorkspace]>.Continuation] = [:] @@ -72,6 +76,8 @@ public final class HiveRemoteMacSession { /// - macDeviceID: The Mac's stable device id. /// - displayName: The Mac's display name. /// - routes: The paired record's attach routes. + /// - sourceRoutes: The unmodified registry routes used for binding-change + /// comparisons, when `routes` has been canonicalized by admission. /// - retryDelay: Awaited between reconnect attempts with the /// consecutive-failure count; production passes a bounded backoff /// sleep, tests a recorder that returns immediately. @@ -85,6 +91,7 @@ public final class HiveRemoteMacSession { macDeviceID: String, displayName: String, routes: [CmxAttachRoute], + sourceRoutes: [CmxAttachRoute]? = nil, retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void, stackAuthChannelTrust: MobileShellRouteAuthPolicy.StackAuthChannelTrust = .loopbackOnly, expectedInstanceTag: String? = nil, @@ -94,6 +101,7 @@ public final class HiveRemoteMacSession { self.macDeviceID = macDeviceID self.displayName = displayName self.routes = routes + self.sourceRoutes = sourceRoutes ?? routes self.expectedInstanceTag = expectedInstanceTag self.requiresHostIdentity = requiresHostIdentity self.retryDelay = retryDelay @@ -136,9 +144,10 @@ public final class HiveRemoteMacSession { } /// Re-fetch the workspace list from the connected Mac. - public func refreshWorkspaces() async { + @discardableResult + public func refreshWorkspaces() async -> Bool { scheduleWorkspaceRefresh() - await workspaceRefreshTask?.value + return await workspaceRefreshTask?.value ?? false } /// Creates a terminal session using this Mac's shared surface-scoped event @@ -168,29 +177,32 @@ public final class HiveRemoteMacSession { return } workspaceRefreshTask = Task { [weak self] in - guard let self else { return } - await self.performWorkspaceRefresh() + guard let self else { return false } + let succeeded = await self.performWorkspaceRefresh() self.workspaceRefreshTask = nil guard !Task.isCancelled else { self.workspaceRefreshPending = false - return + return succeeded } if self.workspaceRefreshPending { self.workspaceRefreshPending = false self.scheduleWorkspaceRefresh() } + return succeeded } } - private func performWorkspaceRefresh() async { - guard !Task.isCancelled, let client else { return } + private func performWorkspaceRefresh() async -> Bool { + guard !Task.isCancelled, let client else { return false } do { let refreshed = try await fetchWorkspaces(client: client) - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { return false } setWorkspaces(refreshed) + return true } catch { // Keep the stale list; the event loop's stream death drives the // visible reconnect state. + return false } } @@ -334,7 +346,6 @@ public final class HiveRemoteMacSession { private func runEventLoop(client: MobileCoreRPCClient) async { var consecutiveFailures = 0 while !Task.isCancelled { - let stream = await client.subscribe(to: ["workspace.updated"]) do { // Register the subscription host-side; this also reconnects a // torn-down transport, which is the recovery path after a blip. @@ -346,8 +357,27 @@ public final class HiveRemoteMacSession { consecutiveFailures = 0 guard !Task.isCancelled else { return } phase = .connected - await refreshWorkspaces() + // Refresh before installing the local listener. If setup or + // the refresh fails, no AsyncStream continuation has been + // registered and the retry cannot leak an orphan listener. + guard await refreshWorkspaces() else { + consecutiveFailures += 1 + phase = .reconnecting + await retryDelay(consecutiveFailures) + continue + } guard !Task.isCancelled else { return } + let stream = await client.subscribe(to: ["workspace.updated"]) + // The refresh closes the small handshake/listener race: any + // event emitted before the listener was installed is reflected + // in the authoritative snapshot above. + for await _ in stream { + scheduleWorkspaceRefresh() + } + if Task.isCancelled { return } + phase = .reconnecting + consecutiveFailures += 1 + await retryDelay(consecutiveFailures) } catch is CancellationError { return } catch { @@ -357,14 +387,6 @@ public final class HiveRemoteMacSession { await retryDelay(consecutiveFailures) continue } - for await _ in stream { - scheduleWorkspaceRefresh() - } - // Stream finished: the transport died. Loop to re-subscribe. - if Task.isCancelled { return } - phase = .reconnecting - consecutiveFailures += 1 - await retryDelay(consecutiveFailures) } } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift index 0867d4e2a9f8..0b931fafe08f 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift @@ -59,11 +59,15 @@ public struct HiveSyncRuntime: MobileSyncRuntime, Sendable { /// authenticated host-identity status probes. /// - stackAccessTokenForceRefresher: Force-mints a fresh token after an /// auth rejection. + /// - verifiedTailscaleHosts: Canonical numeric Tailscale hosts admitted + /// by a local control-plane status snapshot. Bearer routes are rejected + /// when this set is empty or does not contain their host. public static func network( allowsLoopbackRoutes: Bool, stackAccessTokenProvider: @escaping @Sendable () async throws -> String, stackAccessTokenForceRefresher: @escaping @Sendable () async throws -> String, - stackAccessTokenForStatusProvider: @escaping @Sendable () async -> String? = { nil } + stackAccessTokenForStatusProvider: @escaping @Sendable () async -> String? = { nil }, + verifiedTailscaleHosts: Set = [] ) -> HiveSyncRuntime { let supportedKinds: [CmxAttachTransportKind] = allowsLoopbackRoutes ? [.debugLoopback, .tailscale] @@ -77,7 +81,7 @@ public struct HiveSyncRuntime: MobileSyncRuntime, Sendable { CmxRouteTransportFactoryRegistration( kind: kind, factory: kind == .tailscale - ? HiveTailscaleByteTransportFactory() + ? HiveTailscaleByteTransportFactory(admittedHosts: verifiedTailscaleHosts) : networkFactory ) } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift index de3b71bfe92b..54d2ad44b47b 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift @@ -13,8 +13,14 @@ internal import CmuxMobileTransport /// inside the tailnet address space (`100.64/10`, Tailscale's IPv6 ULA, or /// `*.ts.net`); anything else fails exactly like the shared factory. public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { + private let admittedHosts: Set + /// Creates the factory. - public init() {} + /// - Parameter admittedHosts: Canonical numeric hosts present in an + /// authenticated local Tailscale status snapshot. + public init(admittedHosts: Set = []) { + self.admittedHosts = admittedHosts + } /// Route-only variant; same verification as the request variant. public func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { @@ -25,18 +31,20 @@ public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { /// - Throws: `CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable` /// when the route is not a tailnet-classified tailscale host. public func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport { - // The Mac viewer's registry-backed routes are admitted by the - // tailnet address check below and by MobileShellRouteAuthPolicy's - // `.loopbackAndTailscaleTunnel` trust. MobileCoreRPCClient uses the - // `.stackBearer` intent for this existing RPC lane; keep accepting the - // transport-admission intent too for callers that already completed an - // out-of-band handshake. + // The Mac viewer's bearer route must have an authenticated local + // Tailscale status proof. The range check in `makeVerifiedTransport` + // remains a structural guard, not an authorization decision. guard request.authorizationMode == .stackBearer || request.authorizationMode == .transportAdmission else { throw CmxNetworkByteTransportError.unsupportedAuthorizationMode( request.authorizationMode ) } + guard case let .hostPort(host, _) = request.route.endpoint, + let canonicalHost = CmxTailscalePeerAddress(host)?.value, + admittedHosts.contains(canonicalHost) else { + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + } return try makeVerifiedTransport(route: request.route) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 9a136accda65..60d4bb8d6e8c 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -370,10 +370,11 @@ private struct ComputersSectionRow: View { private var title: String { if computer.isThisMac { - return String( + let template = String( localized: "settings.computers.row.thisMac", - defaultValue: "\(computer.name) (This Mac)" + defaultValue: "%@ (This Mac)" ) + return String.localizedStringWithFormat(template, computer.name) } return computer.name } @@ -399,10 +400,11 @@ private struct ComputersSectionRow: View { } guard let lastSeenAt else { return nil } let relative = lastSeenAt.formatted(.relative(presentation: .named)) - return String( + let template = String( localized: "settings.computers.row.lastSeen", - defaultValue: "Last seen \(relative)" + defaultValue: "Last seen %@" ) + return String.localizedStringWithFormat(template, relative) } @ViewBuilder diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift index 360a9da287d3..5116bdf6199b 100644 --- a/Sources/Hive/HiveComputerMirrorController.swift +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -192,10 +192,12 @@ final class HiveComputerMirrorController { forName: NSWindow.willCloseNotification, object: window, queue: .main - ) { [weak self, weak tabManager] _ in - Task { @MainActor [weak self, weak tabManager] in - guard let self, let tabManager else { return } - await self.detach(deviceID: deviceID, from: tabManager) + ) { [weak self] _ in + // Key teardown by the mirror, not by a window context that + // AppDelegate may unregister during the same close turn. + Task { @MainActor [weak self] in + guard let self else { return } + await self.detach(mirrorKey: key) } } } @@ -216,13 +218,14 @@ final class HiveComputerMirrorController { ) guard self.mirrorsByKey[key] === mirror else { return } guard let computer = computers.first(where: { $0.deviceID == deviceID }) else { - continue + await self.detach(deviceID: deviceID, from: tabManager) + return } guard computer.isPaired, let best = computer.bestPairingRoutes else { await self.detach(deviceID: deviceID, from: tabManager) return } - guard best.routes != session.routes + guard best.routes != session.sourceRoutes || best.instanceTag != session.expectedInstanceTag || computer.isRegistryBacked != session.requiresHostIdentity else { continue @@ -271,6 +274,16 @@ final class HiveComputerMirrorController { await teardownMirror(deviceID: deviceID, mirror: mirror, discardEmbeddedSession: shouldDiscard) } + private func detach(mirrorKey key: MirrorKey) async { + guard let mirror = mirrorsByKey.removeValue(forKey: key) else { return } + let shouldDiscard = !mirrorsByKey.keys.contains { $0.deviceID == key.deviceID } + await teardownMirror( + deviceID: key.deviceID, + mirror: mirror, + discardEmbeddedSession: shouldDiscard + ) + } + /// Tear down every embedded mirror, used when account auth ends. func detachAll() async { let mirrors = Array(mirrorsByKey.values) @@ -427,7 +440,11 @@ final class HiveComputerMirrorController { /// Formats a mirror workspace title through the localized format resource /// so creation and later topology/title updates use identical text. private func localizedWorkspaceTitle(remoteTitle: String, computerName: String) -> String { - String(localized: "hive.mirror.workspaceTitle", defaultValue: "\(remoteTitle) — \(computerName)") + let template = String( + localized: "hive.mirror.workspaceTitle", + defaultValue: "%1$@ — %2$@" + ) + return String.localizedStringWithFormat(template, remoteTitle, computerName) } private func addMissingTerminals( diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift index eea249f05f1c..6d8976c31941 100644 --- a/Sources/Hive/HiveComputersService.swift +++ b/Sources/Hive/HiveComputersService.swift @@ -56,31 +56,49 @@ final class HiveComputersService { /// offers Open on paired rows) or auth is not configured. func makeViewerSession(deviceID: String) async -> HiveRemoteMacSession? { guard let auth, let directory else { return nil } - var computer = directory.computers.first(where: { $0.deviceID == deviceID }) - if computer == nil { - // On a fresh launch the directory is empty until something (the - // Settings pane, the scope picker) refreshes it. An open request - // arriving first — relaunch restore, `hive.open` RPC — must not - // fail on that ordering; load the pairings/registry and re-check. - await directory.refresh() - computer = directory.computers.first(where: { $0.deviceID == deviceID }) - } + // Always bind a viewer to the current account/team snapshot. A cached + // row can otherwise survive a team switch and authorize the old + // team's routes until the next settings refresh. + await directory.refresh() + return await makeViewerSessionFromCurrentDirectory( + deviceID: deviceID, + auth: auth, + directory: directory + ) + } + + private func makeViewerSessionFromCurrentDirectory( + deviceID: String, + auth: AuthCoordinator, + directory: HiveComputerDirectory, + admission: HiveTailscaleRouteAdmission.Result? = nil + ) async -> HiveRemoteMacSession? { + let computer = directory.computers.first(where: { $0.deviceID == deviceID }) guard let computer else { return nil } guard computer.isPaired else { return nil } // Prefer the freshest routes: a live online instance's advertised set, // falling back to whatever the pairing/registry row carries. guard let best = computer.bestPairingRoutes else { return nil } + let routeAdmission: HiveTailscaleRouteAdmission.Result + if let admission { + routeAdmission = admission + } else { + routeAdmission = await HiveTailscaleRouteAdmission().admit(routes: best.routes) + } + guard !routeAdmission.routes.isEmpty else { return nil } let runtime = HiveSyncRuntime.network( allowsLoopbackRoutes: Self.allowsLoopbackPairing, stackAccessTokenProvider: { try await auth.accessToken() }, stackAccessTokenForceRefresher: { try await auth.forceRefreshAccessToken() }, - stackAccessTokenForStatusProvider: { try? await auth.accessToken() } + stackAccessTokenForStatusProvider: { try? await auth.accessToken() }, + verifiedTailscaleHosts: routeAdmission.verifiedTailscaleHosts ) return HiveRemoteMacSession( runtime: runtime, macDeviceID: deviceID, displayName: computer.displayName, - routes: best.routes, + routes: routeAdmission.routes, + sourceRoutes: best.routes, retryDelay: { @Sendable attempt in await HiveReconnectBackoff().delay(attempt: attempt) }, @@ -100,17 +118,20 @@ final class HiveComputersService { /// (`computers.presentation = sidebar`), one per device, so scope /// switches reuse the connection instead of re-dialing. private var embeddedSessions: [String: HiveRemoteMacSession] = [:] + private var embeddedScope: HiveAccountScope? /// The cached embedded-viewer session for a device, creating and /// connecting one on first use. Returns `nil` when the computer has no /// pairing record or auth is not configured. func embeddedSession(deviceID: String) async -> HiveRemoteMacSession? { + guard let directory else { return nil } + await refreshEmbeddedDirectory(directory) if let existing = embeddedSessions[deviceID] { - let computer = directory?.computers.first(where: { $0.deviceID == deviceID }) + let computer = directory.computers.first(where: { $0.deviceID == deviceID }) let best = computer?.bestPairingRoutes let bindingChanged = computer == nil || computer?.isPaired != true - || best?.routes != existing.routes + || best?.routes != existing.sourceRoutes || best?.instanceTag != existing.expectedInstanceTag || computer?.isRegistryBacked != existing.requiresHostIdentity if bindingChanged { @@ -120,7 +141,20 @@ final class HiveComputersService { return existing } } - guard let session = await makeViewerSession(deviceID: deviceID) else { return nil } + guard let auth else { return nil } + let currentComputer = directory.computers.first(where: { $0.deviceID == deviceID }) + let currentAdmission: HiveTailscaleRouteAdmission.Result? + if let best = currentComputer?.bestPairingRoutes { + currentAdmission = await HiveTailscaleRouteAdmission().admit(routes: best.routes) + } else { + currentAdmission = nil + } + guard let session = await makeViewerSessionFromCurrentDirectory( + deviceID: deviceID, + auth: auth, + directory: directory, + admission: currentAdmission + ) else { return nil } // Re-check after the await: a concurrent first call may have won. if let existing = embeddedSessions[deviceID] { await session.disconnect() @@ -143,12 +177,37 @@ final class HiveComputersService { await HiveViewerWindowController.shared.closeAll() let sessions = embeddedSessions.values embeddedSessions.removeAll() + embeddedScope = nil for session in sessions { await session.disconnect() } directory?.clearForSignOut() } + private func refreshEmbeddedDirectory(_ directory: HiveComputerDirectory) async { + await directory.refresh() + guard let auth else { + let staleSessions = Array(embeddedSessions.values) + embeddedSessions.removeAll() + embeddedScope = nil + for session in staleSessions { + await session.disconnect() + } + return + } + let scope = HiveAccountScope( + stackUserID: auth.currentUser?.id, + teamID: auth.resolvedTeamID + ) + guard embeddedScope != scope else { return } + let staleSessions = Array(embeddedSessions.values) + embeddedSessions.removeAll() + embeddedScope = scope + for session in staleSessions { + await session.disconnect() + } + } + /// The live connection phase for a device's embedded viewer session /// (Settings Open, sidebar scope picker, or `hive.open`), if one has been /// created. `nil` before any attach attempt — callers treat that as "never diff --git a/Sources/Hive/HiveTailscaleRouteAdmission.swift b/Sources/Hive/HiveTailscaleRouteAdmission.swift new file mode 100644 index 000000000000..e6e5b2be9f04 --- /dev/null +++ b/Sources/Hive/HiveTailscaleRouteAdmission.swift @@ -0,0 +1,87 @@ +import CMUXMobileCore +import CmuxHive +import Foundation + +/// Binds viewer routes to one authenticated local Tailscale control-plane +/// snapshot before a Stack bearer may be sent over the route. +/// +/// The registry's route kind and an address-range check are not peer +/// authentication. This service resolves MagicDNS names through +/// `tailscale status --json`, verifies numeric addresses against the same +/// snapshot, and rewrites admitted routes to canonical numeric addresses. +/// Routes that cannot be proven are dropped so callers fail closed. +struct HiveTailscaleRouteAdmission: Sendable { + /// The result consumed by the RPC runtime composition. + struct Result: Sendable { + /// Routes that are either non-Tailscale or control-plane-admitted. + let routes: [CmxAttachRoute] + /// Canonical numeric Tailscale hosts admitted by the snapshot. + let verifiedTailscaleHosts: Set + } + + private let statusProvider: any TailscaleStatusProviding + private let resolver: CmxTailscaleStatusPeerResolver + + /// Creates an admission service over the local Tailscale status provider. + /// - Parameter statusProvider: Injectable daemon seam for tests. + init(statusProvider: any TailscaleStatusProviding = SystemTailscaleStatusProvider()) { + self.statusProvider = statusProvider + self.resolver = CmxTailscaleStatusPeerResolver() + } + + /// Admit the subset of routes that the local daemon can prove belongs to + /// a remote peer. Non-Tailscale routes pass through unchanged. + func admit(routes: [CmxAttachRoute]) async -> Result { + guard routes.contains(where: { $0.kind == .tailscale }) else { + return Result(routes: routes, verifiedTailscaleHosts: []) + } + guard let statusJSON = try? await statusProvider.statusJSON() else { + return Result( + routes: routes.filter { $0.kind != .tailscale }, + verifiedTailscaleHosts: [] + ) + } + + var admittedRoutes: [CmxAttachRoute] = [] + var verifiedHosts = Set() + for route in routes { + guard route.kind == .tailscale else { + admittedRoutes.append(route) + continue + } + guard case let .hostPort(host, port) = route.endpoint, + let verifiedHost = verifiedHost(host, statusJSON: statusJSON), + let admittedRoute = try? CmxAttachRoute( + id: route.id, + kind: route.kind, + endpoint: .hostPort(host: verifiedHost, port: port), + priority: route.priority + ) else { + continue + } + admittedRoutes.append(admittedRoute) + verifiedHosts.insert(verifiedHost) + } + return Result(routes: admittedRoutes, verifiedTailscaleHosts: verifiedHosts) + } + + private func verifiedHost(_ rawHost: String, statusJSON: Data) -> String? { + let host = rawHost.hasPrefix("[") && rawHost.hasSuffix("]") + ? String(rawHost.dropFirst().dropLast()) + : rawHost + if let address = CmxTailscalePeerAddress(host) { + guard (try? resolver.containsRemotePeerAddress(host, statusJSON: statusJSON)) == true else { + return nil + } + return address.value + } + guard host.lowercased().hasSuffix(".ts.net"), + let record = try? resolver.resolve( + magicDNSName: host, + statusJSON: statusJSON + ) else { + return nil + } + return record.preferredAddress.value + } +} diff --git a/Sources/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift index 0010595d9259..442dd19633f8 100644 --- a/Sources/Hive/HiveViewerWindowController.swift +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -100,18 +100,22 @@ final class HiveViewerWindowController: NSObject, NSWindowDelegate { guard let self, let entry = self.viewersByDeviceID[deviceID], entry.session === currentSession else { return } - guard let computer = computers.first(where: { $0.deviceID == deviceID }), - computer.isPaired, - let best = computer.bestPairingRoutes else { - continue + guard let computer = computers.first(where: { $0.deviceID == deviceID }) else { + await self.close(deviceID: deviceID) + return + } + guard computer.isPaired, let best = computer.bestPairingRoutes else { + await self.close(deviceID: deviceID) + return } - guard best.routes != session.routes - || best.instanceTag != session.expectedInstanceTag - || computer.isRegistryBacked != session.requiresHostIdentity else { + guard best.routes != currentSession.sourceRoutes + || best.instanceTag != currentSession.expectedInstanceTag + || computer.isRegistryBacked != currentSession.requiresHostIdentity else { continue } guard let replacement = await HiveComputersService.shared.makeViewerSession(deviceID: deviceID) else { - continue + await self.close(deviceID: deviceID) + return } replacement.connect() entry.session = replacement From f5b59bcace3b3f87943cd759d9f19bfcca9e97c6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 22:23:17 -0700 Subject: [PATCH 49/97] Wire Hive route admission into the app target --- cmux.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index df1dc3741f61..a7e9fd9e7261 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -923,6 +923,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */; }; C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D2 /* HiveComputersService.swift */; }; 7DD3FE43EB968D44E5652135 /* HiveSidebarConnectionStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */; }; + C41FE00000000000000000C7 /* HiveTailscaleRouteAdmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000E2 /* HiveTailscaleRouteAdmission.swift */; }; C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D5 /* HiveViewerWindowController.swift */; }; CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */; }; @@ -2913,6 +2914,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputerMirrorController.swift; sourceTree = ""; }; C41FE00000000000000000D2 /* HiveComputersService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveComputersService.swift; sourceTree = ""; }; 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveSidebarConnectionStatusView.swift; sourceTree = ""; }; + C41FE00000000000000000E2 /* HiveTailscaleRouteAdmission.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveTailscaleRouteAdmission.swift; sourceTree = ""; }; C41FE00000000000000000D5 /* HiveViewerWindowController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveViewerWindowController.swift; sourceTree = ""; }; CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/HostedInspectorDockControlScript.swift; sourceTree = ""; }; @@ -4242,6 +4244,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = children = ( C41FE00000000000000000D2 /* HiveComputersService.swift */, C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */, + C41FE00000000000000000E2 /* HiveTailscaleRouteAdmission.swift */, C41FE00000000000000000D5 /* HiveViewerWindowController.swift */, 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */, ); @@ -7067,6 +7070,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */, C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */, 7DD3FE43EB968D44E5652135 /* HiveSidebarConnectionStatusView.swift in Sources */, + C41FE00000000000000000C7 /* HiveTailscaleRouteAdmission.swift in Sources */, C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */, From ca9b868db438bc35c0bfbf519bba77a68dd02606 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 24 Aug 2026 22:48:40 -0700 Subject: [PATCH 50/97] Close Hive pairing and sidebar failure gaps --- .../CmxTailscaleStatusPeerResolverTests.swift | 9 +- .../CmuxHive/HiveComputerDirectory.swift | 7 + .../Sources/CmuxHive/HivePairOutcome.swift | 3 + .../HiveComputerDirectoryTests.swift | 11 +- .../Sections/ComputersSection.swift | 2 +- Resources/Localizable.xcstrings | 29054 +++++++++++++++- Sources/ContentView.swift | 10 +- .../Hive/HiveComputerMirrorController.swift | 21 +- Sources/Hive/HiveComputersService.swift | 8 +- .../HiveSidebarConnectionStatusView.swift | 12 +- Sources/HostSettingsActions+Computers.swift | 1 + 11 files changed, 29113 insertions(+), 25 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift index 83516b3438a1..d73eff35ea6e 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxTailscaleStatusPeerResolverTests.swift @@ -200,9 +200,12 @@ import Testing ] ) - #expect(try resolver.containsRemotePeerAddress("100.71.210.41", statusJSON: status)) - #expect(!(try resolver.containsRemotePeerAddress("100.72.1.9", statusJSON: status))) - #expect(!(try resolver.containsRemotePeerAddress("100.70.1.5", statusJSON: status))) + let remoteAdmitted = try resolver.containsRemotePeerAddress("100.71.210.41", statusJSON: status) + let unknownRejected = try resolver.containsRemotePeerAddress("100.72.1.9", statusJSON: status) + let localRejected = try resolver.containsRemotePeerAddress("100.70.1.5", statusJSON: status) + #expect(remoteAdmitted) + #expect(!unknownRejected) + #expect(!localRejected) } private func statusJSON( diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift index d205ecfbc4e7..6926c64a56e1 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -348,6 +348,13 @@ public final class HiveComputerDirectory { // the first connection adopt the host-reported identity. let macDeviceID: String if ticket.macDeviceID.isEmpty { + guard !ticket.routes.contains(where: { $0.kind == .tailscale }) else { + // A tokenless V2 link intentionally omits the host + // identity. Do not persist a synthetic Tailscale row: it + // could never pass the viewer's authenticated admission + // and would report a misleading successful pairing. + return .unsupportedManualRoute + } guard let synthesized = Self.syntheticDeviceID(for: ticket.routes) else { return .invalidLink } diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift index d888a7fe7e20..7e566416e166 100644 --- a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift @@ -15,6 +15,9 @@ public enum HivePairOutcome: Equatable, Sendable { case loopbackRejected /// The link belongs to a different signed-in account than this Mac. case accountMismatch + /// A tokenless V2 link carries only a Tailscale route; it cannot establish + /// the host identity needed before a bearer-capable session is persisted. + case unsupportedManualRoute /// The registry row has no dialable route to persist. case noRoutes /// Persisting the pairing failed (local store error). diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift index 8c0841a1ca86..00197bd7afa2 100644 --- a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -358,7 +358,7 @@ private func makeDirectory( } @MainActor - @Test func pairFromV2LinkSynthesizesIdentityUntilHandshake() async throws { + @Test func pairFromV2TailscaleLinkRequiresRegistryIdentity() async throws { let (store, cleanup) = try makeTempStore() defer { cleanup() } // A v2 pairing link (the QR payload) names routes only, never a @@ -384,13 +384,10 @@ private func makeDirectory( let directory = makeDirectory(registry: .ok([]), store: store) let outcome = await directory.pair(link: link) - #expect(outcome == .paired(deviceID: "manual-100.64.0.7:8123")) + #expect(outcome == .unsupportedManualRoute) let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") - #expect(persisted.first?.macDeviceID == "manual-100.64.0.7:8123") - #expect(persisted.first?.routes.first?.endpoint == .hostPort(host: "100.64.0.7", port: 8123)) - let row = try #require(directory.computers.first) - #expect(row.isPaired) - #expect(row.displayName == "100.64.0.7:8123") + #expect(persisted.isEmpty) + #expect(directory.computers.isEmpty) } @MainActor diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift index 60d4bb8d6e8c..8999ca9c9d78 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -337,7 +337,7 @@ public struct ComputersSection: View { case .accountMismatch: return String(localized: "settings.computers.pairResult.accountMismatch", defaultValue: "That computer is signed in to a different account.") case .noRoutes: - return String(localized: "settings.computers.pairResult.noRoutes", defaultValue: "That computer hasn't advertised a reachable address yet. Make sure Tailscale is running on it.") + return String(localized: "settings.computers.pairResult.noRoutes", defaultValue: "That pairing has no trusted route. Pair from a Computers registry row and make sure Tailscale is running on both Macs.") case .failed: return String(localized: "settings.computers.pairResult.failed", defaultValue: "Pairing failed. Try again.") } diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dd38a20575fd..029f24fba366 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -15736,6 +15736,18 @@ "state": "translated", "value": "取消" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បោះបង់" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скасувати" + } } } }, @@ -17705,6 +17717,18 @@ "state": "translated", "value": "輸入此標籤頁的自訂名稱。" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បញ្ចូលឈ្មោះផ្ទាល់ខ្លួនសម្រាប់ផ្ទាំងនេះ។" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Введіть власну назву для цієї вкладки." + } } } }, @@ -17818,6 +17842,18 @@ "state": "translated", "value": "標籤頁名稱" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ឈ្មោះផ្ទាំង" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Назва вкладки" + } } } }, @@ -17931,6 +17967,18 @@ "state": "translated", "value": "重新命名" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្តូរឈ្មោះ" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перейменувати" + } } } }, @@ -18044,6 +18092,18 @@ "state": "translated", "value": "重新命名標籤頁" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្តូរឈ្មោះផ្ទាំង" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перейменувати вкладку" + } } } }, @@ -37130,6 +37190,114 @@ "state": "translated", "value": "cmux は hooks ディレクトリを作成できませんでした: %@ にファイルがあります。競合するファイルを削除または名前変更してから `cmux hooks setup` を再実行してください" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على cmux إنشاء دليل hooks: يوجد ملف في %@. أزل الملف المتعارض أو أعد تسميته، ثم شغّل `cmux hooks setup` مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao kreirati direktorij hookova: datoteka postoji na %@. Uklonite ili preimenujte konfliktnu datoteku, a zatim ponovo pokrenite `cmux hooks setup`." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke oprette hookmappen: der findes en fil på %@. Fjern eller omdøb filen, og kør derefter `cmux hooks setup` igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte das Hook-Verzeichnis nicht erstellen: Unter %@ ist bereits eine Datei vorhanden. Entferne die Datei oder benenne sie um und führe dann `cmux hooks setup` erneut aus." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo crear el directorio de hooks: ya existe un archivo en %@. Elimina o cambia el nombre del archivo en conflicto y vuelve a ejecutar `cmux hooks setup`." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n’a pas pu créer le dossier des hooks : un fichier existe à l’emplacement %@. Supprimez ou renommez ce fichier, puis relancez `cmux hooks setup`." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a creare la directory degli hook: esiste già un file in %@. Rimuovi o rinomina il file in conflitto, quindi esegui di nuovo `cmux hooks setup`." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux មិនអាចបង្កើតថត hooks បានទេ៖ មានឯកសារមួយនៅ %@។ លុប ឬប្តូរឈ្មោះឯកសារដែលប៉ះទង្គិច រួចដំណើរការ `cmux hooks setup` ម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 hooks 디렉터리를 만들지 못했습니다. %@에 파일이 있습니다. 충돌하는 파일을 제거하거나 이름을 바꾼 다음 `cmux hooks setup`을 다시 실행하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke opprette hookmappen: det finnes en fil på %@. Fjern eller gi nytt navn til filen, og kjør deretter `cmux hooks setup` på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł utworzyć katalogu hooków: w %@ istnieje plik. Usuń plik powodujący konflikt lub zmień jego nazwę, a następnie ponownie uruchom `cmux hooks setup`." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu criar o diretório de hooks: existe um arquivo em %@. Remova ou renomeie o arquivo conflitante e execute `cmux hooks setup` novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось создать каталог хуков: по пути %@ уже существует файл. Удалите или переименуйте этот файл, затем снова выполните `cmux hooks setup`." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถสร้างไดเรกทอรี hooks ได้ เนื่องจากมีไฟล์อยู่ที่ %@ โปรดลบหรือเปลี่ยนชื่อไฟล์ที่ขัดแย้ง แล้วเรียกใช้ `cmux hooks setup` อีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux hooks dizinini oluşturamadı: %@ konumunda bir dosya var. Çakışan dosyayı kaldırın veya yeniden adlandırın, ardından `cmux hooks setup` komutunu tekrar çalıştırın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux не вдалося створити каталог хуків: за шляхом %@ вже існує файл. Видаліть або перейменуйте цей файл, а потім знову виконайте `cmux hooks setup`." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法创建 hooks 目录:%@ 处已有文件。请移除或重命名冲突文件,然后再次运行 `cmux hooks setup`。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法建立 hooks 目錄:%@ 已有檔案。請移除或重新命名衝突的檔案,然後再次執行 `cmux hooks setup`。" + } } } }, @@ -128705,6 +128873,114 @@ "state": "translated", "value": "エージェントの通知を cmux の iPhone アプリに送信します。デフォルトはオフで、オンにしない限り何もアップロードされません。" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أرسل إشعارات الوكيل إلى تطبيق cmux على iPhone. تكون الميزة متوقفة افتراضيًا، ولا يتم رفع أي شيء إلا عند تشغيلها." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Šalji obavještenja agenata u cmux aplikaciju za iPhone. Isključeno je po zadanim postavkama; ništa se ne šalje dok ovo ne uključite." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Send agentnotifikationer til cmux-appen på iPhone. Funktionen er slået fra som standard; intet uploades, medmindre den er slået til." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Agentenmitteilungen an die cmux-App auf dem iPhone senden. Standardmäßig deaktiviert; ohne Aktivierung wird nichts hochgeladen." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Envía notificaciones de agentes a la app cmux del iPhone. Está desactivado de forma predeterminada; no se carga nada a menos que lo actives." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyez les notifications des agents à l’app cmux sur iPhone. Cette option est désactivée par défaut ; rien n’est téléversé tant qu’elle ne l’est pas." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia le notifiche degli agenti all’app cmux per iPhone. L’opzione è disattivata per impostazione predefinita; non viene caricato nulla finché non la attivi." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ញើការជូនដំណឹងរបស់ភ្នាក់ងារទៅកម្មវិធី cmux លើ iPhone។ មុខងារនេះបិទតាមលំនាំដើម ហើយគ្មានអ្វីត្រូវបានផ្ទុកឡើងទេ លុះត្រាតែអ្នកបើកវា។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 알림을 iPhone용 cmux 앱으로 보냅니다. 기본적으로 꺼져 있으며, 이 설정을 켜기 전에는 아무것도 업로드되지 않습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Send agentvarslinger til cmux-appen på iPhone. Funksjonen er av som standard; ingenting lastes opp med mindre den er slått på." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wysyłaj powiadomienia agentów do aplikacji cmux na iPhonie. Funkcja jest domyślnie wyłączona; nic nie jest przesyłane, dopóki jej nie włączysz." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Envie notificações dos agentes ao app cmux no iPhone. O recurso vem desativado; nada é enviado até que você o ative." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправлять уведомления агентов в приложение cmux на iPhone. По умолчанию выключено; данные не загружаются, пока вы не включите эту функцию." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งการแจ้งเตือนของเอเจนต์ไปยังแอป cmux บน iPhone ปิดไว้ตามค่าเริ่มต้น และจะไม่มีการอัปโหลดข้อมูลจนกว่าจะเปิดใช้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Aracı bildirimlerini iPhone’daki cmux uygulamasına gönderir. Varsayılan olarak kapalıdır; açılmadıkça hiçbir şey yüklenmez." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надсилати сповіщення агентів у застосунок cmux на iPhone. Типово вимкнено; нічого не завантажується, доки ви не ввімкнете цю функцію." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将代理通知发送到 iPhone 上的 cmux App。默认关闭;只有开启后才会上传内容。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將代理程式通知傳送到 iPhone 上的 cmux App。預設關閉;只有開啟後才會上傳內容。" + } } } }, @@ -128722,6 +128998,114 @@ "state": "translated", "value": "通知を iPhone に転送する" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة توجيه الإشعارات إلى iPhone الخاص بي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Proslijedi obavještenja na moj iPhone" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Videresend notifikationer til min iPhone" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mitteilungen an mein iPhone weiterleiten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reenviar notificaciones a mi iPhone" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Transférer les notifications vers mon iPhone" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Inoltra le notifiche al mio iPhone" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បញ្ជូនការជូនដំណឹងទៅ iPhone របស់ខ្ញុំ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "내 iPhone으로 알림 전달" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Videresend varslinger til min iPhone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przekazuj powiadomienia na mój iPhone" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Encaminhar notificações para meu iPhone" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пересылать уведомления на мой iPhone" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งต่อการแจ้งเตือนไปยัง iPhone ของฉัน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bildirimleri iPhone'uma ilet" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Пересилати сповіщення на мій iPhone" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将通知转发到我的 iPhone" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將通知轉寄到我的 iPhone" + } } } }, @@ -137553,6 +137937,108 @@ "state": "translated", "value": "Hermes Agent" } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "وكيل Hermes" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Hermes agent" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hermes-agent" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hermes-Agent" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Agente Hermes" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Agent Hermes" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Agente Hermes" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្នាក់ងារ Hermes" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hermes-agent" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Agent Hermes" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Agente Hermes" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Агент Hermes" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอเจนต์ Hermes" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hermes Aracısı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Агент Hermes" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Hermes 代理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Hermes 代理程式" + } } } }, @@ -164023,13 +164509,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "That computer hasn't advertised a reachable address yet. Make sure Tailscale is running on it." + "value": "That pairing has no trusted route. Pair from a Computers registry row and make sure Tailscale is running on both Macs." } }, "ja": { "stringUnit": { "state": "translated", - "value": "そのコンピュータはまだ接続可能なアドレスを公開していません。Tailscale が実行中か確認してください。" + "value": "信頼できる接続経路がありません。Computers の登録行からペアリングし、両方の Mac で Tailscale が実行中であることを確認してください。" } } } @@ -168332,6 +168818,114 @@ "state": "translated", "value": "デスクトップ通知" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桌面通知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "桌面通知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데스크톱 알림" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Desktop-Benachrichtigungen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Notificaciones de escritorio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Notifications du bureau" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Notifiche desktop" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsnotifikationer" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia na pulpicie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления на рабочем столе" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja na radnoj površini" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إشعارات سطح المكتب" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsvarsler" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Notificações da área de trabalho" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือนบนเดสก์ท็อป" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Masaüstü Bildirimleri" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сповіщення на робочому столі" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការជូនដំណឹងលើផ្ទៃតុ" + } } } }, @@ -168349,6 +168943,114 @@ "state": "translated", "value": "有効にする" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "활성화" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Aktivieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Activar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Activer" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Abilita" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå til" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Włącz" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Включить" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Omogući" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمكين" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Aktiver" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Ativar" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Etkinleştir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Увімкнути" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បើក" + } } } }, @@ -168366,6 +169068,114 @@ "state": "translated", "value": "テストを送信" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "发送测试" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "傳送測試" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "테스트 보내기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Test senden" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Enviar prueba" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Envoyer un test" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Invia test" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Send test" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyślij test" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отправить тест" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pošalji test" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرسال اختبار" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Send test" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Enviar teste" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งการทดสอบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Test Gönder" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Надіслати тест" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ញើការសាកល្បង" + } } } }, @@ -168383,6 +169193,114 @@ "state": "translated", "value": "権限が不明です" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "权限未知" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "權限未知" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "권한 알 수 없음" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Berechtigung unbekannt" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permiso desconocido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autorisation inconnue" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Autorizzazione sconosciuta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilladelse ukendt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieznane uprawnienie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешение неизвестно" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dozvola nepoznata" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الإذن غير معروف" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tillatelse ukjent" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Permissão desconhecida" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ทราบสิทธิ์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İzin bilinmiyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дозвіл невідомий" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនស្គាល់សិទ្ធិអនុញ្ញាត" + } } } }, @@ -168400,6 +169318,114 @@ "state": "translated", "value": "デスクトップ通知はまだ有効になっていません。" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "桌面通知尚未启用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "桌面通知尚未啟用。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "데스크톱 알림이 아직 활성화되지 않았습니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Desktop-Benachrichtigungen sind noch nicht aktiviert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las notificaciones de escritorio aún no están activadas." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les notifications du bureau ne sont pas encore activées." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Le notifiche desktop non sono ancora abilitate." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsnotifikationer er ikke slået til endnu." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Powiadomienia na pulpicie nie są jeszcze włączone." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Уведомления на рабочем столе еще не включены." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Obavještenja na radnoj površini još nisu omogućena." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تمكين إشعارات سطح المكتب بعد." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Skrivebordsvarsler er ikke aktivert ennå." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "As notificações da área de trabalho ainda não estão ativadas." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การแจ้งเตือนบนเดสก์ท็อปยังไม่ได้เปิดใช้งาน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Masaüstü bildirimleri henüz etkin değil." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сповіщення на робочому столі ще не увімкнено." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការជូនដំណឹងលើផ្ទៃតុមិនទាន់ត្រូវបានបើកទេ។" + } } } }, @@ -239547,6 +240573,28030 @@ } } } + }, + "caffeine.error.missingEnabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pass enabled=true or enabled=false." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "enabled=true または enabled=false を指定してください。" + } + } + } + }, + "caffeine.error.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Keep Mac Awake isn't available yet." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「Macをスリープさせない」はまだ利用できません。" + } + } + } + }, + "cli.help.notify.reply": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--reply Allow a free-text inline reply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--reply 自由入力のインライン返信を許可" + } + } + } + }, + "debug.menu.notification.all": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Emit All" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて送信" + } + } + } + }, + "debug.menu.notification.cli": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CLI" + } + } + } + }, + "debug.menu.notification.cliReply": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "CLI Reply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CLI 返信" + } + } + } + }, + "debug.menu.notification.emit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Emit Notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を送信" + } + } + } + }, + "debug.menu.notification.error": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Error" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エラー" + } + } + } + }, + "debug.menu.notification.feedExitPlan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Exit Plan" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード終了プラン" + } + } + } + }, + "debug.menu.notification.feedPermission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード権限" + } + } + } + }, + "debug.menu.notification.feedQuestion": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Question" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード質問" + } + } + } + }, + "debug.menu.notification.feedQuestion4": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Question (4 Options)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード質問(4つの選択肢)" + } + } + } + }, + "debug.menu.notification.feedQuestionMany": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Question (Many Options)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード質問(多数の選択肢)" + } + } + } + }, + "debug.menu.notification.feedQuestionMultiple": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Question (Multiple)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード質問(複数質問)" + } + } + } + }, + "debug.menu.notification.feedQuestionMultiSelect": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Feed Question (Multi-select)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フィード質問(複数選択)" + } + } + } + }, + "debug.menu.notification.hookFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hook Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フック失敗" + } + } + } + }, + "debug.menu.notification.idle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Idle Reminder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アイドル通知" + } + } + } + }, + "debug.menu.notification.mode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification Debug Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知デバッグモード" + } + } + } + }, + "debug.menu.notification.needsPermission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Needs Permission" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限が必要" + } + } + } + }, + "debug.menu.notification.osc": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "OSC" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "OSC" + } + } + } + }, + "debug.menu.notification.turnComplete": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Turn Complete" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターン完了" + } + } + } + }, + "debug.notification.error.invalidKindOrTarget": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown kind or no notification target" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明な種類、または通知対象がありません" + } + } + } + }, + "debug.notification.error.missingEnabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pass enabled=true or enabled=false to turn notification debug mode on or off." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "enabled=true または enabled=false を指定して、通知デバッグモードのオン/オフを切り替えてください。" + } + } + } + }, + "debug.notification.error.missingKind": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pass kind= to choose which debug notification to emit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "kind=<通知の種類> を指定して、発行するデバッグ通知を選んでください。" + } + } + } + }, + "debug.notification.synthetic.body": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Synthetic %@ notification" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "合成 %@ 通知" + } + } + } + }, + "debug.notification.synthetic.option": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Option %d" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択肢 %d" + } + } + } + }, + "debug.notification.synthetic.plan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Inspect the notification and verify the actions." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知を確認し、アクションを検証します。" + } + } + } + }, + "debug.notification.synthetic.question": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a notification debug answer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知デバッグの回答を選択" + } + } + } + }, + "debug.notification.synthetic.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Notification Debug" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "通知デバッグ" + } + } + } + }, + "feed.notification.exitPlan.revise": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Revise…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "修正…" + } + } + } + }, + "feed.notification.question.other": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Other…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "その他…" + } + } + } + }, + "terminal.notification.action.reply": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reply" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "返信" + } + } + } + }, + "terminal.notification.action.replyPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message the agent…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントにメッセージ…" + } + } + } + }, + "terminal.notification.action.replySend": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Send" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "送信" + } + } + } + }, + "account.signIn.browserOpenFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t open your browser. Copy the link and paste it into any browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを開けませんでした。リンクをコピーして任意のブラウザに貼り付けてください。" + } + } + } + }, + "account.signIn.browserOpened": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser opened. Complete sign-in there." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザを開きました。そこでサインインを完了してください。" + } + } + } + }, + "account.signIn.copyFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t copy the link. Open it in your browser instead." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクをコピーできませんでした。代わりにブラウザで開いてください。" + } + } + } + }, + "account.signIn.copyLink": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Sign-In Link" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインリンクをコピー" + } + } + } + }, + "account.signIn.error.browserUnavailable.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open the link in your browser, or copy it and paste it into any browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザでリンクを開くか、リンクをコピーして任意のブラウザに貼り付けてください。" + } + } + } + }, + "account.signIn.error.browserUnavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t open sign-in" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインを開けませんでした" + } + } + } + }, + "account.signIn.error.cancelled.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No changes were made. Try again when you’re ready, or use the browser link below." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更はありません。準備ができたらもう一度試すか、下のブラウザリンクを使用してください。" + } + } + } + }, + "account.signIn.error.cancelled.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in canceled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインをキャンセルしました" + } + } + } + }, + "account.signIn.error.invalidLink.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again to create a fresh secure link. The old link cannot sign in." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一度試して新しい安全なリンクを作成してください。古いリンクではサインインできません。" + } + } + } + }, + "account.signIn.error.invalidLink.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "That sign-in link is no longer valid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのサインインリンクは無効です" + } + } + } + }, + "account.signIn.error.network.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check your connection, then try again. Your account was not changed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続を確認してから、もう一度試してください。アカウントは変更されていません。" + } + } + } + }, + "account.signIn.error.network.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t reach cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux に接続できません" + } + } + } + }, + "account.signIn.error.offline.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to Wi-Fi or another network, then try again. You can keep this pane open." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Wi-Fi または別のネットワークに接続して、もう一度試してください。このペインは開いたままにできます。" + } + } + } + }, + "account.signIn.error.offline.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No internet connection" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インターネットに接続されていません" + } + } + } + }, + "account.signIn.error.rejected.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Check the account details in the browser and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザでアカウント情報を確認して、もう一度試してください。" + } + } + } + }, + "account.signIn.error.rejected.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn’t complete the sign-in" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux でサインインを完了できませんでした" + } + } + } + }, + "account.signIn.error.server.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again in a moment. Your account and existing cmux workspaces are unchanged." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "しばらくしてからもう一度試してください。アカウントと既存の cmux ワークスペースは変更されていません。" + } + } + } + }, + "account.signIn.error.server.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux sign-in is temporarily unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux のサインインは一時的に利用できません" + } + } + } + }, + "account.signIn.error.timedOut.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in window did not return to cmux. Reopen the browser link or start a new attempt." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインウインドウから cmux に戻りませんでした。ブラウザリンクをもう一度開くか、新しいサインインを開始してください。" + } + } + } + }, + "account.signIn.error.timedOut.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in timed out" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインがタイムアウトしました" + } + } + } + }, + "account.signIn.error.unauthorized.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Confirm you’re using the intended cmux account, then try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用する cmux アカウントが正しいことを確認して、もう一度試してください。" + } + } + } + }, + "account.signIn.error.unauthorized.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux couldn’t authorize this sign-in" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux がこのサインインを承認できませんでした" + } + } + } + }, + "account.signIn.error.unknown.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again. If the sign-in window still fails, copy the link and open it in your browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一度試してください。サインインウインドウが引き続き失敗する場合は、リンクをコピーしてブラウザで開いてください。" + } + } + } + }, + "account.signIn.error.unknown.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn’t finish sign-in" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインを完了できませんでした" + } + } + } + }, + "account.signIn.heading": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to cmux" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux にサインイン" + } + } + } + }, + "account.signIn.instructions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete sign-in in the window that opened. This pane updates automatically." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開いたウインドウでサインインを完了してください。このペインは自動的に更新されます。" + } + } + } + }, + "account.signIn.linkCopied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Link copied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リンクをコピーしました" + } + } + } + }, + "account.signIn.loading.finishing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Finishing sign-in…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインを完了しています…" + } + } + } + }, + "account.signIn.loading.finishing.instructions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux is verifying your account. Keep this pane open." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux がアカウントを確認しています。このペインを開いたままにしてください。" + } + } + } + }, + "account.signIn.loading.opening": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opening secure sign-in…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "安全なサインインを開いています…" + } + } + } + }, + "account.signIn.loading.opening.instructions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux is preparing a secure sign-in window." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux が安全なサインインウインドウを準備しています。" + } + } + } + }, + "account.signIn.loading.waiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for sign-in…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインを待機中…" + } + } + } + }, + "account.signIn.loading.waiting.instructions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete sign-in in the window that opened. This pane updates automatically." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開いたウインドウでサインインを完了してください。このペインは自動的に更新されます。" + } + } + } + }, + "account.signIn.loading.waitingSlow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Still waiting for sign-in…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "引き続きサインインを待機中…" + } + } + } + }, + "account.signIn.loading.waitingSlow.instructions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in window is taking longer than expected. You can reopen or copy the same secure link without restarting." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインウインドウに時間がかかっています。最初からやり直さずに、同じ安全なリンクをもう一度開くかコピーできます。" + } + } + } + }, + "account.signIn.notCompleted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign-in wasn’t completed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインが完了しませんでした。" + } + } + } + }, + "account.signIn.openInBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Again in Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザでもう一度開く" + } + } + } + }, + "account.signIn.prompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue with your cmux account." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux アカウントで続行します。" + } + } + } + }, + "account.signIn.start": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign In" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインイン" + } + } + } + }, + "account.signIn.successBody": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your cmux account is connected." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux アカウントに接続しました。" + } + } + } + }, + "account.signIn.successTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You’re signed in now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインしました" + } + } + } + }, + "account.signIn.tryAgain": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try Again" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一度試す" + } + } + } + }, + "account.signIn.waiting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for sign-in…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインを待機中…" + } + } + } + }, + "account.signIn.workspace.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign In" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインイン" + } + } + } + }, + "agent.deliveryTarget.error.invalidPidResolution": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "pid_resolution must be corroborated or controlling_tty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "pid_resolution は corroborated または controlling_tty でなければなりません。" + } + } + } + }, + "cli.agentHook.error.notificationDelivery": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تسليم إشعار خطاف الوكيل لـ %@ %@." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dostava obavijesti agentske kuke nije uspjela za %@ %@." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Levering af agent-hook-notifikationen mislykkedes for %@ %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Zustellung der Agent-Hook-Benachrichtigung ist für %@ %@ fehlgeschlagen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent hook notification delivery failed for %@ %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo entregar la notificación del hook del agente para %@ %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la livraison de la notification du hook d’agent pour %@ %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile recapitare la notifica dell’hook dell’agente per %@ %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ のエージェントフック通知を配信できませんでした。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការបញ្ជូនការជូនដំណឹង agent hook បានបរាជ័យសម្រាប់ %@ %@។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@에 대한 에이전트 훅 알림을 전달하지 못했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Levering av agent-hook-varslingen mislyktes for %@ %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się dostarczyć powiadomienia haka agenta dla %@ %@." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao entregar a notificação do hook do agente para %@ %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось доставить уведомление хука агента для %@ %@." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งการแจ้งเตือน agent hook สำหรับ %@ %@ ไม่สำเร็จ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ için aracı kancası bildirimi teslim edilemedi." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося доставити сповіщення хуку агента для %@ %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法传递 %@ %@ 的代理钩子通知。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法傳遞 %@ %@ 的代理程式掛鉤通知。" + } + } + } + }, + "cli.agentHook.error.journalAppend": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل إلحاق سجل خطاف الوكيل لـ %@ %@." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Dodavanje agentske kuke u dnevnik nije uspjelo za %@ %@." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Tilføjelse af agent-hook til journalen mislykkedes for %@ %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Anhängen des Agent-Hooks an das Journal ist für %@ %@ fehlgeschlagen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent hook journal append failed for %@ %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo añadir el hook del agente al diario para %@ %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de l’ajout du hook d’agent au journal pour %@ %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile aggiungere l’hook dell’agente al diario per %@ %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ のエージェントフックのジャーナル追記に失敗しました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការបន្ថែម agent hook ទៅក្នុងកំណត់ហេតុបានបរាជ័យសម្រាប់ %@ %@។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@에 대한 에이전트 훅 저널 추가에 실패했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Det oppstod en feil ved å legge agent-hooken til journalen for %@ %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się dodać haka agenta do dziennika dla %@ %@." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao adicionar o hook do agente ao diário para %@ %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось добавить хук агента в журнал для %@ %@." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เพิ่ม agent hook ลงในบันทึกประจำวันสำหรับ %@ %@ ไม่สำเร็จ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ için aracı kancasını günlüğe ekleme başarısız oldu." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося додати хук агента до журналу для %@ %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法将 %@ %@ 的代理钩子追加到日志。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法將 %@ %@ 的代理程式掛鉤追加到日誌。" + } + } + } + }, + "cli.agentHook.error.targetResolution": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل حل هدف خطاف الوكيل لـ %@ %@." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Rješavanje odredišta agentske kuke nije uspjelo za %@ %@." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agent-hook-målløsning mislykkedes for %@ %@." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Zielauflösung des Agent-Hooks ist für %@ %@ fehlgeschlagen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Agent hook target resolution failed for %@ %@." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo resolver el destino del hook del agente para %@ %@." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la résolution de la cible du hook d’agent pour %@ %@." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Risoluzione della destinazione dell’hook dell’agente non riuscita per %@ %@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ のエージェントフック配信先を解決できませんでした。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការដោះស្រាយគោលដៅ agent hook បានបរាជ័យសម្រាប់ %@ %@។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ %@에 대한 에이전트 훅 대상을 확인하지 못했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fant ikke målet for agent-hooken for %@ %@." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się rozpoznać celu haka agenta dla %@ %@." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao resolver o destino do hook do agente para %@ %@." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось определить цель хука агента для %@ %@." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถแก้ไขปลายทาง agent hook สำหรับ %@ %@ ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ için aracı kancası hedefi çözülemedi." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося визначити ціль хуку агента для %@ %@." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法解析 %@ %@ 的代理钩子目标。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法解析 %@ %@ 的代理程式掛鉤目標。" + } + } + } + }, + "agent.kimi.displayName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Kimi Code" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Kimi Code" + } + } + } + }, + "browser.designMode.buttonHelpFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Design Mode (%@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインモード(%@)" + } + } + } + }, + "browser.designMode.composer.describeChange": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Describe the change" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "変更内容を入力" + } + } + } + }, + "browser.designMode.composer.pickElements": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select one or more elements on the page." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ上の要素を1つ以上選択してください。" + } + } + } + }, + "browser.designMode.composer.removeSelection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove selected element" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した要素を削除" + } + } + } + }, + "browser.designMode.context.remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove %@ context" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ のコンテキストを削除" + } + } + } + }, + "browser.designMode.copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー" + } + } + } + }, + "browser.designMode.copy.copied": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copied" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー済み" + } + } + } + }, + "browser.designMode.copy.copying": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copying…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コピー中…" + } + } + } + }, + "browser.designMode.copy.shortcut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "⌘↩" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "⌘↩" + } + } + } + }, + "browser.designMode.error.annotationCapture": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not capture the annotation. Draw it again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "注釈をキャプチャできませんでした。もう一度描いてください。" + } + } + } + }, + "browser.designMode.error.captureChanged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected element moved during capture. Try again when the page is still." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャプチャ中に選択した要素が移動しました。ページが静止しているときにもう一度お試しください。" + } + } + } + }, + "browser.designMode.error.copy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not copy the design context." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインコンテキストをコピーできませんでした。" + } + } + } + }, + "browser.designMode.error.disable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Design Mode cleanup failed. Reload the page or try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインモードのクリーンアップに失敗しました。ページを再読み込みするか、もう一度お試しください。" + } + } + } + }, + "browser.designMode.error.enable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Design Mode could not start." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインモードを開始できませんでした。" + } + } + } + }, + "browser.designMode.error.invalidRuntimeResponse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The page returned invalid design data." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページから無効なデザインデータが返されました。" + } + } + } + }, + "browser.designMode.error.noPage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open a page before entering Design Mode." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインモードを開始する前にページを開いてください。" + } + } + } + }, + "browser.designMode.error.operationTimedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The page stopped responding. Reload it and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページが応答しなくなりました。再読み込みして、もう一度お試しください。" + } + } + } + }, + "browser.designMode.error.recovery": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try again. If the problem continues, reload the page." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "もう一度お試しください。問題が続く場合は、ページを再読み込みしてください。" + } + } + } + }, + "browser.designMode.error.stopForReactGrab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Design Mode could not close before starting React Grab. Reload the page and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "React Grabを開始する前にデザインモードを終了できませんでした。ページを再読み込みして、もう一度お試しください。" + } + } + } + }, + "browser.designMode.error.stopReactGrab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "React Grab could not close. Reload the page and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "React Grabを終了できませんでした。ページを再読み込みして、もう一度お試しください。" + } + } + } + }, + "browser.designMode.error.updateSelection": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not update the selected elements." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択した要素を更新できませんでした。" + } + } + } + }, + "browser.designMode.handoff.contextJSON": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Details: %1$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "詳細: %1$@" + } + } + } + }, + "browser.designMode.handoff.contextOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Design-mode context for the selected page elements." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したページ要素のデザインモードコンテキストです。" + } + } + } + }, + "browser.designMode.handoff.pageURL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Page: %1$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ページ: %1$@" + } + } + } + }, + "browser.designMode.mode.draw": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Draw a capture area" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャプチャ範囲を描画" + } + } + } + }, + "browser.designMode.mode.select": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select elements" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "要素を選択" + } + } + } + }, + "browser.designMode.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Design Mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "デザインモード" + } + } + } + }, + "browser.error.urlAllowlist.attemptedURL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attempted URL" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "試行したURL" + } + } + } + }, + "browser.error.urlAllowlist.message": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This URL is not allowed by your organization's embedded-browser policy." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このURLは組織の内蔵ブラウザポリシーで許可されていません。" + } + } + } + }, + "browser.error.urlAllowlist.userMessage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This URL is not allowed by the embedded-browser URL policy." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このURLは内蔵ブラウザのURLポリシーで許可されていません。" + } + } + } + }, + "browser.error.urlAllowlist.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blocked by your organization's policy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組織のポリシーによりブロックされました" + } + } + } + }, + "browser.error.urlAllowlist.userTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blocked by the embedded-browser URL policy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ブラウザのURLポリシーによりブロックされました" + } + } + } + }, + "browser.profile.automation.error.browserDisabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser profiles cannot be used while the cmux browser is disabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux ブラウザーが無効な間はブラウザープロファイルを使用できません" + } + } + } + }, + "browser.profile.automation.error.invalidProfileSelector": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser profile must be a non-empty name or UUID" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイルには空でない名前または UUID を指定してください" + } + } + } + }, + "browser.profile.automation.error.multipleProfileSelectors": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Specify only one browser profile selector" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイルセレクターは 1 つだけ指定してください" + } + } + } + }, + "browser.profile.automation.error.profileRequiresBrowserPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser profiles can only be used when creating a browser pane" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザープロファイルはブラウザーペインの作成時にのみ使用できます" + } + } + } + }, + "browser.profile.automation.error.profileUnavailableInRemoteWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser profiles cannot be selected when creating a browser pane in a remote workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペースでブラウザーペインを作成する場合、ブラウザープロファイルは選択できません" + } + } + } + }, + "browser.screenshot.error.captureInProgress": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Another browser screenshot is already in progress." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "別のブラウザのスクリーンショットを取得中です。" + } + } + } + }, + "browser.screenshot.error.renderedContentMismatch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The browser screenshot disagreed with rendered text at %1$@; %2$lld text probes were blank after %3$lld attempts." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザのスクリーンショットが %1$@ の描画済みテキストと一致しません。%3$lld 回試行した後も %2$lld 個のテキストプローブが空白でした。" + } + } + } + }, + "cli.browser.designMode.disabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "disabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効" + } + } + } + }, + "cli.browser.designMode.enabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "enabled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "有効" + } + } + } + }, + "cli.browser.designMode.expectedModes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser design-mode requires one of: enable, disable, toggle, status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser design-mode には enable、disable、toggle、status のいずれかを指定してください" + } + } + } + }, + "cli.browser.designMode.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser design-mode enable|disable|toggle|status [--surface ]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser design-mode enable|disable|toggle|status [--surface ]" + } + } + } + }, + "cli.browser.designMode.rpcExpectedModes": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "mode must be one of: enable, disable, toggle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "mode は enable、disable、toggle のいずれかである必要があります" + } + } + } + }, + "cli.browser.designMode.timeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out updating browser design mode" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザデザインモードの更新がタイムアウトしました" + } + } + } + }, + "cli.browser.designMode.updateFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser design mode could not be updated" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザデザインモードを更新できませんでした" + } + } + } + }, + "cli.browser.error.diffViewerAllowlistInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid trusted diff viewer allowlist" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼済み差分ビューアの許可リストが無効です" + } + } + } + }, + "cli.browser.error.diffViewerAllowlistMissingOrInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing or invalid trusted diff viewer allowlist" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼済み差分ビューアの許可リストがないか、無効です" + } + } + } + }, + "cli.browser.error.diffViewerSessionInvalid": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid trusted diff viewer session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼済み差分ビューアのセッションが無効です" + } + } + } + }, + "cli.browser.error.diffViewerSessionMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing trusted diff viewer session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "信頼済み差分ビューアのセッションがありません" + } + } + } + }, + "cli.browser.error.urlBlockedByPolicy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blocked by your organization's browser policy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "組織のブラウザポリシーによりブロックされました" + } + } + } + }, + "cli.browser.error.urlBlockedByUserPolicy": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blocked by the embedded-browser URL policy" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "内蔵ブラウザのURLポリシーによりブロックされました" + } + } + } + }, + "cli.browser.error.operationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser operation failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ操作に失敗しました" + } + } + } + }, + "cli.browser.cookies.clearRequiresScope": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear requires exactly one of --all or a cookie scope" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear には --all または Cookie スコープを1つだけ指定してください" + } + } + } + }, + "cli.browser.cookies.invalidExpires": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear --expires must be an integer Unix timestamp" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear --expires には Unix 時刻の整数を指定してください" + } + } + } + }, + "cli.browser.cookies.invalidFilter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear received an invalid cookie filter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear に無効な Cookie フィルターが指定されました" + } + } + } + }, + "cli.browser.cookies.invalidURL": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear --url requires a valid URL with a host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser cookies clear --url にはホストを含む有効な URL を指定してください" + } + } + } + }, + "cli.browser.error.tabManagerUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser controls are unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザ機能を利用できません" + } + } + } + }, + "cli.browser.profile.error.emptySelector": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--profile requires a non-empty profile name or UUID" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--profile には空でないプロファイル名または UUID が必要です" + } + } + } + }, + "cli.browser.profile.error.navigateUnsupported": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--profile is only supported when browser open creates a new pane; omit the surface selector" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--profile は browser open が新しいペインを作成する場合にのみ使用できます。サーフェスセレクターを省略してください" + } + } + } + }, + "cli.browser.profile.option": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "[--profile ]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "[--profile <名前|UUID>]" + } + } + } + }, + "cli.browser.profiles.marker.lastUsed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "last used" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "前回使用" + } + } + } + }, + "cli.canvas.error.invalidPaneType": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "type must be terminal, browser, or simulator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "typeにはterminal、browser、simulatorのいずれかを指定してください" + } + } + } + }, + "cli.canvas.error.newPaneTypeUsage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux canvas new-pane [--type terminal|browser|simulator]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法: cmux canvas new-pane [--type terminal|browser|simulator]" + } + } + } + }, + "cli.closeSurface.error.explicitSurfaceRequiresWorkspaceOrWindow": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب close-surface الخيار --workspace أو --window عند تحديد --surface صراحةً" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "close-surface zahtijeva --workspace ili --window uz eksplicitni --surface" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "close-surface kræver --workspace eller --window med eksplicit --surface" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "close-surface erfordert --workspace oder --window mit explizitem --surface" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "close-surface requires --workspace or --window with explicit --surface" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "close-surface requiere --workspace o --window con --surface explícito" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "close-surface requiert --workspace ou --window avec --surface explicite" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "close-surface richiede --workspace o --window con --surface esplicito" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "明示的に --surface を指定する場合、close-surface には --workspace または --window が必要です" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "close-surface ត្រូវការ --workspace ឬ --window ជាមួយ --surface ដែលបានបញ្ជាក់ច្បាស់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "명시적 --surface에는 close-surface에 --workspace 또는 --window가 필요합니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "close-surface krever --workspace eller --window med eksplisitt --surface" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "close-surface wymaga --workspace albo --window przy jawnym --surface" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "close-surface requer --workspace ou --window com --surface explícito" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "close-surface требует --workspace или --window при явном --surface" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "close-surface ต้องใช้ --workspace หรือ --window เมื่อระบุ --surface อย่างชัดเจน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Açık --surface ile close-surface için --workspace veya --window gerekir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "close-surface потребує --workspace або --window з явним --surface" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "显式指定 --surface 时,close-surface 需要 --workspace 或 --window" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "明確指定 --surface 時,close-surface 需要 --workspace 或 --window" + } + } + } + }, + "cli.comments.error.notARepository": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب cmux comments مستودع git: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux comments zahtijeva git repozitorij: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux comments kræver et git-repository: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux comments benötigt ein Git-Repository: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux comments requires a git repository: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux comments requiere un repositorio git: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux comments nécessite un dépôt git : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux comments richiede un repository git: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux comments には git リポジトリが必要です: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux comments ត្រូវការ​ឃ្លាំង git: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux comments 는 git 저장소에서 실행해야 합니다: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux comments krever et git-repository: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux comments wymaga repozytorium git: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "cmux comments requer um repositório git: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux comments требует git-репозиторий: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux comments ต้องใช้ที่เก็บโค้ด git: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux comments bir git deposu gerektirir: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux comments потребує git-репозиторію: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux comments 需要 git 仓库:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux comments 需要 git 儲存庫:%@" + } + } + } + }, + "cli.comments.error.repoRequiresPath": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب --repo مسارًا. إذا كان المسار يبدأ بشرطة، فمرره بالصيغة ./-name" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "--repo zahtijeva putanju. Putanju koja počinje crticom navedite kao ./-name" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "--repo kræver en sti. En sti der starter med bindestreg, angives som ./-name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "--repo benötigt einen Pfad. Einen Pfad, der mit einem Bindestrich beginnt, als ./-name angeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "--repo requires a path. For a path starting with a dash, pass it as ./-name" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "--repo requiere una ruta. Si la ruta empieza por guion, indíquela como ./-name" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "--repo nécessite un chemin. Pour un chemin commençant par un tiret, écrivez ./-name" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "--repo richiede un percorso. Per un percorso che inizia con un trattino, usa ./-name" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--repo にはパスが必要です。ダッシュで始まるパスは ./-name の形式で指定してください" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "--repo ត្រូវការ​ផ្លូវ។ ផ្លូវ​ដែល​ចាប់ផ្តើម​ដោយ​សញ្ញា​ដាច់ សូម​សរសេរ​ជា ./-name" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "--repo 에는 경로가 필요합니다. 대시로 시작하는 경로는 ./-name 형식으로 넘기세요" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "--repo krever en sti. En sti som starter med bindestrek, oppgis som ./-name" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "--repo wymaga ścieżki. Ścieżkę zaczynającą się od myślnika podaj jako ./-name" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "--repo requer um caminho. Para um caminho que começa com hífen, use ./-name" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "--repo требует путь. Путь, начинающийся с дефиса, укажите как ./-name" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "--repo ต้องระบุพาธ หากพาธเริ่มด้วยขีดกลาง ให้ระบุเป็น ./-name" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "--repo bir yol gerektirir. Kısa çizgiyle başlayan yolu ./-name olarak verin" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "--repo потребує шлях. Шлях, що починається з дефіса, вкажіть як ./-name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "--repo 需要提供路径。以短横线开头的路径请写成 ./-name" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "--repo 需要提供路徑。以連字號開頭的路徑請寫成 ./-name" + } + } + } + }, + "cli.comments.error.subcommandRequired": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب comments أمرًا فرعيًا. جرّب: list" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "comments zahtijeva potkomandu. Pokušajte: list" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "comments kræver en underkommando. Prøv: list" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "comments benötigt einen Unterbefehl. Versuche: list" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "comments requires a subcommand. Try: list" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "comments requiere un subcomando. Prueba: list" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "comments nécessite une sous-commande. Essayez : list" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "comments richiede un sottocomando. Prova: list" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "comments にはサブコマンドが必要です。例: list" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "comments ត្រូវការ​អនុ​បញ្ជា។ សូម​សាកល្បង: list" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "comments 에는 서브커맨드가 필요합니다. 예: list" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "comments krever en underkommando. Prøv: list" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "comments wymaga podkomendy. Spróbuj: list" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "comments requer um subcomando. Tente: list" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "comments требует подкоманду. Попробуйте: list" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "comments ต้องระบุคำสั่งย่อย ลอง: list" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "comments bir alt komut gerektirir. Deneyin: list" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "comments потребує підкоманду. Спробуйте: list" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "comments 需要子命令。可用:list" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "comments 需要子命令。可用:list" + } + } + } + }, + "cli.comments.error.unexpectedArgument": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وسيطة غير متوقعة '%@' في cmux comments list. المدعوم: --repo و--all و--json" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Neočekivani argument '%@' za cmux comments list. Podržano: --repo , --all, --json" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Uventet argument '%@' til cmux comments list. Understøttet: --repo , --all, --json" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unerwartetes Argument '%@' für cmux comments list. Unterstützt: --repo , --all, --json" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unexpected argument '%@' for cmux comments list. Supported: --repo , --all, --json" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Argumento inesperado '%@' en cmux comments list. Admitidos: --repo , --all, --json" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Argument inattendu '%@' pour cmux comments list. Pris en charge : --repo , --all, --json" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Argomento inatteso '%@' per cmux comments list. Supportati: --repo , --all, --json" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux comments list に予期しない引数 '%@' が指定されました。使用できるのは --repo , --all, --json です" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អាគុយម៉ង់ '%@' មិន​រំពឹង​ទុក​សម្រាប់ cmux comments list។ ដែល​ប្រើ​បាន: --repo , --all, --json" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux comments list 에 예상치 못한 인자 '%@' 입니다. 사용 가능: --repo , --all, --json" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uventet argument '%@' for cmux comments list. Støttet: --repo , --all, --json" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieoczekiwany argument '%@' dla cmux comments list. Obsługiwane: --repo , --all, --json" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Argumento inesperado '%@' em cmux comments list. Suportados: --repo , --all, --json" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неожиданный аргумент '%@' для cmux comments list. Поддерживаются: --repo , --all, --json" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อาร์กิวเมนต์ '%@' ไม่ถูกต้องสำหรับ cmux comments list รองรับ: --repo , --all, --json" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux comments list için beklenmeyen argüman '%@'. Desteklenen: --repo , --all, --json" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Неочікуваний аргумент '%@' для cmux comments list. Підтримуються: --repo , --all, --json" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux comments list 收到意外参数 '%@'。支持:--repo 、--all、--json" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux comments list 收到未預期的參數 '%@'。支援:--repo 、--all、--json" + } + } + } + }, + "cli.comments.error.unknownSubcommand": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أمر فرعي غير معروف '%@' في comments. جرّب: list" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nepoznata potkomanda '%@' za comments. Pokušajte: list" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ukendt comments-underkommando '%@'. Prøv: list" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannter comments-Unterbefehl '%@'. Versuche: list" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown comments subcommand '%@'. Try: list" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Subcomando de comments desconocido '%@'. Prueba: list" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Sous-commande comments inconnue '%@'. Essayez : list" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sottocomando comments sconosciuto '%@'. Prova: list" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "comments のサブコマンド '%@' は不明です。例: list" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អនុ​បញ្ជា comments '%@' មិន​ស្គាល់។ សូម​សាកល្បង: list" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 comments 서브커맨드 '%@' 입니다. 예: list" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ukjent comments-underkommando '%@'. Prøv: list" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieznana podkomenda comments '%@'. Spróbuj: list" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Subcomando de comments desconhecido '%@'. Tente: list" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Неизвестная подкоманда comments '%@'. Попробуйте: list" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่รู้จักคำสั่งย่อย '%@' ของ comments ลอง: list" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bilinmeyen comments alt komutu '%@'. Deneyin: list" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Невідома підкоманда comments '%@'. Спробуйте: list" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知的 comments 子命令 '%@'。可用:list" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知的 comments 子命令 '%@'。可用:list" + } + } + } + }, + "cli.comments.list.anchor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": " المرساة: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": " sidro: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": " anker: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": " Anker: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": " anchor: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": " ancla: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": " ancre : %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": " ancora: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": " アンカー: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": " យុថ្កា: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": " 앵커: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": " anker: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": " zakotwiczenie: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": " âncora: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": " привязка: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": " จุดยึด: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": " bağlantı noktası: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": " прив'язка: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": " 锚点:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": " 錨點:%@" + } + } + } + }, + "cli.comments.list.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا توجد تعليقات مراجعة. (المستودع: %@)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nema komentara pregleda. (repozitorij: %@)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen gennemgangskommentarer. (repository: %@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Review-Kommentare. (Repository: %@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No review comments. (repo: %@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No hay comentarios de revisión. (repositorio: %@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun commentaire de revue. (dépôt : %@)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessun commento di revisione. (repository: %@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レビューコメントはありません。(リポジトリ: %@)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "គ្មាន​មតិ​ត្រួតពិនិត្យ។ (ឃ្លាំង: %@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리뷰 코멘트가 없습니다. (저장소: %@)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ingen gjennomgangskommentarer. (repository: %@)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak komentarzy przeglądu. (repozytorium: %@)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhum comentário de revisão. (repositório: %@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Комментариев ревью нет. (репозиторий: %@)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่มีคอมเมนต์รีวิว (ที่เก็บโค้ด: %@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İnceleme yorumu yok. (depo: %@)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Немає коментарів рецензування. (репозиторій: %@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有评审评论。(仓库:%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有審查註解。(儲存庫:%@)" + } + } + } + }, + "cli.comments.list.header.one": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليق مراجعة واحد (المستودع: %@)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "1 komentar pregleda (repozitorij: %@)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "1 gennemgangskommentar (repository: %@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "1 Review-Kommentar (Repository: %@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "1 review comment (repo: %@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "1 comentario de revisión (repositorio: %@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "1 commentaire de revue (dépôt : %@)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "1 commento di revisione (repository: %@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レビューコメント 1 件 (リポジトリ: %@)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មតិ​ត្រួតពិនិត្យ 1 (ឃ្លាំង: %@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리뷰 코멘트 1건 (저장소: %@)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "1 gjennomgangskommentar (repository: %@)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "1 komentarz przeglądu (repozytorium: %@)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "1 comentário de revisão (repositório: %@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "1 комментарий ревью (репозиторий: %@)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คอมเมนต์รีวิว 1 รายการ (ที่เก็บโค้ด: %@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "1 inceleme yorumu (depo: %@)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "1 коментар рецензування (репозиторій: %@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "1 条评审评论(仓库:%@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "1 則審查註解(儲存庫:%@)" + } + } + } + }, + "cli.comments.list.header.other": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعليقات المراجعة: %1$lld (المستودع: %2$@)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%1$lld komentara pregleda (repozitorij: %2$@)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%1$lld gennemgangskommentarer (repository: %2$@)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$lld Review-Kommentare (Repository: %2$@)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$lld review comments (repo: %2$@)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$lld comentarios de revisión (repositorio: %2$@)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$lld commentaires de revue (dépôt : %2$@)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%1$lld commenti di revisione (repository: %2$@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "レビューコメント %1$lld 件 (リポジトリ: %2$@)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មតិ​ត្រួតពិនិត្យ %1$lld (ឃ្លាំង: %2$@)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리뷰 코멘트 %1$lld건 (저장소: %2$@)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%1$lld gjennomgangskommentarer (repository: %2$@)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Komentarze przeglądu: %1$lld (repozytorium: %2$@)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%1$lld comentários de revisão (repositório: %2$@)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Комментарии ревью: %1$lld (репозиторий: %2$@)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คอมเมนต์รีวิว %1$lld รายการ (ที่เก็บโค้ด: %2$@)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%1$lld inceleme yorumu (depo: %2$@)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Коментарі рецензування: %1$lld (репозиторій: %2$@)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 条评审评论(仓库:%2$@)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$lld 則審查註解(儲存庫:%2$@)" + } + } + } + }, + "cli.comments.list.stateConsumed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم التسليم" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "predano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "leveret" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "übergeben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "consumed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "entregado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "transmis" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "consegnato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "引き渡し済み" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បាន​ប្រគល់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전달됨" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "levert" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "przekazany" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "entregue" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "передан" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "teslim edildi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "передано" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已交付" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已交付" + } + } + } + }, + "cli.comments.list.statePending": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قيد الانتظار" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "na čekanju" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "afventer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "offen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "pending" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "pendiente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "en attente" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "in sospeso" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未対応" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រង់ចាំ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "미해결" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "venter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "oczekujący" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "pendente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "в ожидании" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "รอดำเนินการ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "beklemede" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "очікує" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "待处理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "待處理" + } + } + } + }, + "cli.surface.error.handleBlank": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف السطح فارغ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ručka površine je prazna" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overfladehåndtaget er tomt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberflächen-Handle ist leer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface handle is blank" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El identificador de superficie está vacío" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’identifiant de surface est vide" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "L'handle della superficie è vuoto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスハンドルが空です" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ហែនឌែលផ្ទៃទទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서피스 핸들이 비어 있습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Overflatehåndtaket er tomt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uchwyt powierzchni jest pusty" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O identificador da superfície está vazio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Дескриптор поверхности пуст" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แฮนเดิลพื้นผิวว่างเปล่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yüzey tanıtıcısı boş" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Дескриптор поверхні порожній" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "表面句柄为空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "表面控制代碼為空" + } + } + } + }, + "cli.surface.error.indexNotFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على فهرس السطح" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Indeks površine nije pronađen" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overfladeindeks blev ikke fundet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberflächenindex nicht gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface index not found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró el índice de superficie" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Index de surface introuvable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Indice superficie non trovato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスインデックスが見つかりません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រកមិនឃើញលិបិក្រមផ្ទៃ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서피스 인덱스를 찾을 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fant ikke overflateindeks" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono indeksu powierzchni" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Índice de superfície não encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Индекс поверхности не найден" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบดัชนีพื้นผิว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yüzey dizini bulunamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Індекс поверхні не знайдено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到表面索引" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到表面索引" + } + } + } + }, + "cli.surface.error.invalidHandle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف سطح غير صالح: %@ (المتوقع UUID أو مرجع مثل surface:1 أو فهرس)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nevažeća ručka površine: %@ (očekuje se UUID, referenca poput surface:1 ili indeks)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ugyldigt overfladehåndtag: %@ (forventede UUID, reference som surface:1 eller indeks)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültiges Oberflächen-Handle: %@ (UUID, Referenz wie surface:1 oder Index erwartet)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid surface handle: %@ (expected UUID, ref like surface:1, or index)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Identificador de superficie no válido: %@ (se esperaba UUID, referencia como surface:1 o índice)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Identifiant de surface invalide: %@ (UUID, référence comme surface:1 ou index attendu)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Handle superficie non valido: %@ (previsto UUID, riferimento come surface:1 o indice)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "無効なサーフェスハンドル: %@ (UUID、surface:1 のような参照、またはインデックスを指定してください)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ហែនឌែលផ្ទៃមិនត្រឹមត្រូវ: %@ (រំពឹង UUID សេចក្តីយោងដូចជា surface:1 ឬលិបិក្រម)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "잘못된 서피스 핸들: %@ (UUID, surface:1 같은 참조 또는 인덱스 필요)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ugyldig overflatehåndtak: %@ (forventet UUID, referanse som surface:1 eller indeks)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nieprawidłowy uchwyt powierzchni: %@ (oczekiwano UUID, odwołania jak surface:1 albo indeksu)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Identificador de superfície inválido: %@ (esperado UUID, referência como surface:1 ou índice)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недопустимый дескриптор поверхности: %@ (ожидается UUID, ссылка вида surface:1 или индекс)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แฮนเดิลพื้นผิวไม่ถูกต้อง: %@ (ต้องเป็น UUID การอ้างอิงเช่น surface:1 หรือดัชนี)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçersiz yüzey tanıtıcısı: %@ (UUID, surface:1 gibi başvuru veya dizin beklenir)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Недійсний дескриптор поверхні: %@ (очікується UUID, посилання на кшталт surface:1 або індекс)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无效的表面句柄: %@ (应为 UUID、类似 surface:1 的引用或索引)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無效的表面控制代碼: %@ (預期 UUID、類似 surface:1 的參照或索引)" + } + } + } + }, + "cli.surface.error.notFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على السطح: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Površina nije pronađena: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overflade blev ikke fundet: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberfläche nicht gefunden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface not found: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró la superficie: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surface introuvable: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Superficie non trovata: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスが見つかりません: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រកមិនឃើញផ្ទៃ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서피스를 찾을 수 없습니다: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fant ikke overflate: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono powierzchni: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Superfície não encontrada: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поверхность не найдена: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบพื้นผิว: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yüzey bulunamadı: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Поверхню не знайдено: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到表面: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到表面: %@" + } + } + } + }, + "cli.surface.error.refNotFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على مرجع السطح: %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Referenca površine nije pronađena: %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overfladereference blev ikke fundet: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Oberflächenreferenz nicht gefunden: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface ref not found: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró la referencia de superficie: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Référence de surface introuvable: %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Riferimento superficie non trovato: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェス参照が見つかりません: %@" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រកមិនឃើញសេចក្តីយោងផ្ទៃ: %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서피스 참조를 찾을 수 없습니다: %@" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fant ikke overflatereferanse: %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono odwołania do powierzchni: %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Referência de superfície não encontrada: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ссылка на поверхность не найдена: %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบการอ้างอิงพื้นผิว: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yüzey referansı bulunamadı: %@" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Посилання на поверхню не знайдено: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到表面引用: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到表面參照: %@" + } + } + } + }, + "cli.surfaceResume.set.error.unknownFlag": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface resume set: unknown flag '%1$@'. Known flags: %2$@. Use -- for command arguments." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface resume set: 不明なフラグ '%1$@'。利用可能なフラグ: %2$@。コマンド引数には -- を使用してください。" + } + } + } + }, + "cli.codexTeams.watcher.subagent.depthExceeded": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams watcher skipped a Codex subagent: depth %1$lld exceeds the automatic pane limit %2$lld." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams ウォッチャーは Codex サブエージェントをスキップしました: 深さ %1$lld がペイン自動作成の上限 %2$lld を超えています。" + } + } + } + }, + "cli.codexTeams.watcher.subagent.paneCreationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams watcher could not create a pane for a resumed Codex subagent." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams ウォッチャーは再開された Codex サブエージェント用のペインを作成できませんでした。" + } + } + } + }, + "cli.codexTeams.watcher.subagent.paneCreationFailedWithCode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams watcher could not create a pane for a resumed Codex subagent (error code: %@)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams ウォッチャーは再開された Codex サブエージェント用のペインを作成できませんでした(エラーコード: %@)。" + } + } + } + }, + "cli.codexTeams.watcher.subagent.readinessFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams watcher could not attach a Codex subagent because its resumed thread was not ready." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams ウォッチャーは、再開されたスレッドの準備ができていないため Codex サブエージェントをアタッチできませんでした。" + } + } + } + }, + "cli.codexTeams.watcher.subagent.statusNotAttachable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams watcher skipped a Codex subagent: thread status %@ is not attachable." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux codex-teams ウォッチャーは Codex サブエージェントをスキップしました: スレッドの状態 %@ はアタッチ可能ではありません。" + } + } + } + }, + "cli.comments.usage": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux comments [options]\n\nتعليقات المراجعة المحفوظة من عارض الفروق، وتُخزَّن لكل مستودع git.\n\nالأوامر الفرعية:\n list [--repo ] [--all] [--json]\n يسرد تعليقات المراجعة لمستودع (الافتراضي: مستودع git الذي يحتوي\n الدليل الحالي). يسرد التعليقات المعلَّقة فقط؛ ويضيف --all التعليقات\n التي سُلِّمت بالفعل إلى وكيل عبر إرسال TextBox." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upotreba: cmux comments [options]\n\nKomentari pregleda sačuvani iz diff prikaza, čuvaju se po git repozitoriju.\n\nPotkomande:\n list [--repo ] [--all] [--json]\n Ispisuje komentare pregleda za repozitorij (podrazumijevano: git\n repozitorij koji sadrži trenutni direktorij). Ispisuje samo komentare\n na čekanju; --all uključuje i one već predane agentu putem TextBox\n slanja." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug: cmux comments [options]\n\nGennemgangskommentarer gemt fra diff-visningen, lagret pr. git-repository.\n\nUnderkommandoer:\n list [--repo ] [--all] [--json]\n Viser gennemgangskommentarerne for et repository (standard: det\n git-repository der indeholder den aktuelle mappe). Viser kun\n afventende kommentarer; --all medtager også dem der allerede er\n leveret til en agent via en TextBox-indsendelse." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux comments [options]\n\nIm Diff-Viewer gespeicherte Review-Kommentare, abgelegt pro Git-Repository.\n\nUnterbefehle:\n list [--repo ] [--all] [--json]\n Listet die Review-Kommentare eines Repositorys auf (Standard: das\n Git-Repository des aktuellen Verzeichnisses). Zeigt nur offene\n Kommentare; mit --all auch die, die per TextBox-Übermittlung schon an\n einen Agenten übergeben wurden." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux comments [options]\n\nReview comments saved from the diff viewer, stored per git repository.\n\nSubcommands:\n list [--repo ] [--all] [--json]\n List review comments for a repository (default: the git repository\n containing the current directory). Lists pending comments only;\n --all includes comments already delivered to an agent through a\n TextBox submission." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux comments [options]\n\nComentarios de revisión guardados desde el visor de diferencias, almacenados por repositorio git.\n\nSubcomandos:\n list [--repo ] [--all] [--json]\n Lista los comentarios de revisión de un repositorio (por defecto: el\n repositorio git que contiene el directorio actual). Muestra solo los\n comentarios pendientes; --all incluye los ya entregados a un agente\n mediante un envío de TextBox." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux comments [options]\n\nCommentaires de revue enregistrés depuis la visionneuse de diff, stockés par dépôt git.\n\nSous-commandes :\n list [--repo ] [--all] [--json]\n Liste les commentaires de revue d'un dépôt (par défaut : le dépôt git\n contenant le répertoire courant). N'affiche que les commentaires en\n attente ; --all inclut aussi ceux déjà transmis à un agent via un\n envoi TextBox." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux comments [options]\n\nCommenti di revisione salvati dal visualizzatore di diff, memorizzati per repository git.\n\nSottocomandi:\n list [--repo ] [--all] [--json]\n Elenca i commenti di revisione di un repository (predefinito: il\n repository git che contiene la directory corrente). Elenca solo i\n commenti in sospeso; --all include anche quelli già consegnati a un\n agente tramite un invio TextBox." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux comments [options]\n\ndiff ビューアーで保存されたレビューコメントを、git リポジトリごとに一覧します。\n\nサブコマンド:\n list [--repo ] [--all] [--json]\n リポジトリのレビューコメントを一覧します (既定はカレントディレクトリを含む\n git リポジトリ)。既定では未対応のコメントのみを表示し、--all を付けると\n TextBox の送信でエージェントへ引き渡し済みのコメントも含めます。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "របៀប​ប្រើ: cmux comments [options]\n\nមតិ​ត្រួតពិនិត្យ​ដែល​បាន​រក្សាទុក​ពី​កម្មវិធី​មើល diff ត្រូវ​បាន​ទុក​តាម​ឃ្លាំង git។\n\nអនុ​បញ្ជា:\n list [--repo ] [--all] [--json]\n បង្ហាញ​មតិ​ត្រួតពិនិត្យ​របស់​ឃ្លាំង (លំនាំដើម: ឃ្លាំង git ដែល​មាន\n ថត​បច្ចុប្បន្ន)។ បង្ហាញ​តែ​មតិ​ដែល​រង់ចាំ; --all រួម​បញ្ចូល​មតិ​ដែល​បាន\n ប្រគល់​ទៅ​ភ្នាក់ងារ​តាម​ការ​ផ្ញើ TextBox រួច​ហើយ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux comments [options]\n\ndiff 뷰어에서 저장한 리뷰 코멘트를 git 저장소 단위로 조회합니다.\n\n서브커맨드:\n list [--repo ] [--all] [--json]\n 저장소의 리뷰 코멘트를 조회합니다 (기본값은 현재 디렉터리가 속한 git\n 저장소). 기본적으로 미해결 코멘트만 표시하고, --all 을 주면 TextBox\n 전송으로 에이전트에 이미 전달된 코멘트까지 포함합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk: cmux comments [options]\n\nGjennomgangskommentarer lagret fra diff-visningen, lagret per git-repository.\n\nUnderkommandoer:\n list [--repo ] [--all] [--json]\n Lister gjennomgangskommentarene for et repository (standard:\n git-repositoryet som inneholder gjeldende katalog). Lister bare\n ventende kommentarer; --all tar også med dem som alt er levert til en\n agent via en TextBox-sending." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie: cmux comments [options]\n\nKomentarze przeglądu zapisane w podglądzie diff, przechowywane dla każdego repozytorium git.\n\nPodkomendy:\n list [--repo ] [--all] [--json]\n Wypisuje komentarze przeglądu repozytorium (domyślnie: repozytorium\n git zawierające bieżący katalog). Pokazuje tylko oczekujące\n komentarze; --all obejmuje także te już przekazane agentowi przez\n wysłanie TextBox." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux comments [options]\n\nComentários de revisão salvos no visualizador de diff, armazenados por repositório git.\n\nSubcomandos:\n list [--repo ] [--all] [--json]\n Lista os comentários de revisão de um repositório (padrão: o\n repositório git que contém o diretório atual). Lista apenas os\n comentários pendentes; --all inclui os já entregues a um agente por\n um envio do TextBox." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование: cmux comments [options]\n\nКомментарии ревью, сохранённые в просмотрщике diff, хранятся по git-репозиториям.\n\nПодкоманды:\n list [--repo ] [--all] [--json]\n Выводит комментарии ревью репозитория (по умолчанию — git-репозиторий\n текущего каталога). Показывает только ожидающие комментарии; с --all\n также те, что уже переданы агенту через отправку TextBox." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "วิธีใช้: cmux comments [options]\n\nคอมเมนต์รีวิวที่บันทึกจากตัวดูส่วนต่าง จัดเก็บแยกตามที่เก็บโค้ด git\n\nคำสั่งย่อย:\n list [--repo ] [--all] [--json]\n แสดงคอมเมนต์รีวิวของที่เก็บโค้ด (ค่าเริ่มต้น: ที่เก็บโค้ด git\n ที่มีไดเรกทอรีปัจจุบันอยู่) โดยแสดงเฉพาะคอมเมนต์ที่รอดำเนินการ\n ส่วน --all จะรวมคอมเมนต์ที่ส่งให้เอเจนต์ผ่าน TextBox แล้วด้วย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım: cmux comments [options]\n\nDiff görüntüleyicide kaydedilen inceleme yorumları, git deposu başına saklanır.\n\nAlt komutlar:\n list [--repo ] [--all] [--json]\n Bir deponun inceleme yorumlarını listeler (varsayılan: geçerli\n dizini içeren git deposu). Yalnızca bekleyen yorumları listeler;\n --all bir TextBox gönderimiyle bir aracıya zaten teslim edilmiş\n olanları da içerir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання: cmux comments [options]\n\nКоментарі рецензування, збережені у переглядачі diff, зберігаються за git-репозиторіями.\n\nПідкоманди:\n list [--repo ] [--all] [--json]\n Виводить коментарі рецензування репозиторію (типово — git-репозиторій\n поточного каталогу). Показує лише ті, що очікують; з --all також ті,\n що вже передані агентові через надсилання TextBox." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux comments [options]\n\n从差异查看器保存的评审评论,按 git 仓库分别存储。\n\n子命令:\n list [--repo ] [--all] [--json]\n 列出仓库的评审评论(默认:包含当前目录的 git 仓库)。默认只列出待处理\n 的评论;加上 --all 还会列出已通过 TextBox 提交交付给代理的评论。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux comments [options]\n\n從差異檢視器儲存的審查註解,依 git 儲存庫分別存放。\n\n子命令:\n list [--repo ] [--all] [--json]\n 列出儲存庫的審查註解(預設:包含目前目錄的 git 儲存庫)。預設只列出待\n 處理的註解;加上 --all 也會列出已透過 TextBox 提交交付給代理的註解。" + } + } + } + }, + "cli.creation.remoteTmux.newPane": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(routed to remote tmux; the new pane arrives asynchronously)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(リモート tmux にルーティングされました。新しいペインは非同期で反映されます)" + } + } + } + }, + "cli.creation.remoteTmux.newWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "(routed to remote tmux; the new window arrives asynchronously)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "(リモート tmux にルーティングされました。新しいウインドウは非同期で反映されます)" + } + } + } + }, + "cli.send.queuedSuffix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "queued (terminal starting; input will be sent when its PTY is ready)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キュー済み(ターミナル起動中。PTY 生成後に入力が送信されます)" + } + } + } + }, + "cli.error.sshSessionAttachSessionNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: no persisted SSH PTY session with id '%@'. Run 'cmux ssh-session-list --all-workspaces' to see valid session ids." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: ID '%@' の永続 SSH PTY セッションが見つかりません。有効なセッション ID を確認するには 'cmux ssh-session-list --all-workspaces' を実行してください。" + } + } + } + }, + "cli.error.sshSessionAttachStateUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: persisted SSH PTY session state is unavailable. Run 'cmux ssh-session-list --all-workspaces' and retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: 永続 SSH PTY セッションの状態を利用できません。'cmux ssh-session-list --all-workspaces' を実行してから再試行してください。" + } + } + } + }, + "cli.error.sshSessionAttachWorkspaceMismatch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: session '%1$@' belongs to workspace %2$@, but --workspace requested a different workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: セッション '%1$@' はワークスペース %2$@ に属していますが、--workspace では別のワークスペースが指定されました" + } + } + } + }, + "cli.error.sshSessionAttachWorkspaceAmbiguous": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: session '%1$@' exists in multiple workspaces (%2$@). Pass --workspace to choose one." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ssh-session-attach: セッション '%1$@' は複数のワークスペース (%2$@) に存在します。--workspace で指定してください。" + } + } + } + }, + "cli.events.error.invalidTimeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--timeout must be greater than 0" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--timeout は0より大きい値である必要があります" + } + } + } + }, + "cli.events.error.timeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out waiting for a matching event" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するイベントを待機中にタイムアウトしました" + } + } + } + }, + "cli.events.help.snapshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Print the subscription snapshot and exit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "購読スナップショットを表示して終了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구독 스냅샷을 출력하고 종료" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вивести знімок підписки та завершити роботу" + } + } + } + }, + "cli.events.help.timeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Exit unsuccessfully if no matching event arrives before the deadline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限までに一致するイベントが届かない場合はエラーで終了" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제한 시간 전에 일치하는 이벤트가 도착하지 않으면 오류로 종료" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Завершити з помилкою, якщо відповідна подія не надійде до кінцевого терміну" + } + } + } + }, + "cli.help.ios": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "ios [args] [--surface ]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ios [args] [--surface ]" + } + } + } + }, + "cli.help.irohDiag": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux iroh-diag\n\nاطبع تقرير اتصال Iroh للمضيف كمخطط زمني بلغة واضحة،\nوهي البيانات نفسها الموجودة في الإعدادات > الشبكات > تقرير الاتصال." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upotreba: cmux iroh-diag\n\nIspiši izvještaj o Iroh vezi domaćina kao vremensku liniju razumljivim jezikom,\niste podatke kao Postavke > Mreža > Izvještaj o vezi." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug: cmux iroh-diag\n\nUdskriv værtens Iroh-forbindelsesrapport som en tidslinje i almindeligt sprog,\nde samme data som Indstillinger > Netværk > Forbindelsesrapport." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux iroh-diag\n\nGibt den Iroh-Verbindungsbericht des Hosts als verständliche Zeitleiste aus,\ndieselben Daten wie Einstellungen > Netzwerk > Verbindungsbericht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux iroh-diag\n\nPrint the host's Iroh Connection Report as a plain-language timeline,\nthe same data as Settings > Networking > Connection Report." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux iroh-diag\n\nMuestra el informe de conexión Iroh del host como una cronología en lenguaje sencillo,\nlos mismos datos que Ajustes > Red > Informe de conexión." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux iroh-diag\n\nAffiche le rapport de connexion Iroh de l’hôte sous forme de chronologie en langage clair,\nles mêmes données que Réglages > Réseau > Rapport de connexion." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux iroh-diag\n\nStampa il rapporto di connessione Iroh dell’host come cronologia in linguaggio semplice,\ngli stessi dati di Impostazioni > Rete > Rapporto di connessione." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux iroh-diag\n\nホストのIroh接続レポートを読みやすいタイムラインとして出力します。\n設定 > ネットワーク > 接続レポートと同じデータです。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការប្រើប្រាស់៖ cmux iroh-diag\n\nបង្ហាញរបាយការណ៍ការតភ្ជាប់ Iroh របស់ម៉ាស៊ីនជាបន្ទាត់ពេលវេលាដែលងាយយល់\nជាទិន្នន័យដូចគ្នានឹង ការកំណត់ > បណ្តាញ > របាយការណ៍ការតភ្ជាប់។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux iroh-diag\n\n호스트의 Iroh 연결 보고서를 이해하기 쉬운 타임라인으로 출력합니다.\n설정 > 네트워킹 > 연결 보고서와 동일한 데이터입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk: cmux iroh-diag\n\nSkriv ut vertens Iroh-tilkoblingsrapport som en tidslinje i klart språk,\nde samme dataene som Innstillinger > Nettverk > Tilkoblingsrapport." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie: cmux iroh-diag\n\nWyświetla raport połączenia Iroh hosta jako oś czasu napisaną prostym językiem,\nte same dane co Ustawienia > Sieć > Raport połączenia." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux iroh-diag\n\nExibe o relatório de conexão Iroh do host como uma linha do tempo em linguagem simples,\nos mesmos dados de Ajustes > Rede > Relatório de conexão." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование: cmux iroh-diag\n\nВыводит отчет о подключении Iroh хоста в виде понятной временной шкалы,\nте же данные, что в Настройки > Сеть > Отчет о подключении." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งาน: cmux iroh-diag\n\nแสดงรายงานการเชื่อมต่อ Iroh ของโฮสต์เป็นไทม์ไลน์ที่อ่านเข้าใจง่าย\nเป็นข้อมูลเดียวกับ การตั้งค่า > เครือข่าย > รายงานการเชื่อมต่อ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım: cmux iroh-diag\n\nAna bilgisayarın Iroh Bağlantı Raporunu anlaşılır bir zaman çizelgesi olarak yazdırır,\nAyarlar > Ağ > Bağlantı Raporu ile aynı verileri içerir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання: cmux iroh-diag\n\nВиводить звіт про підключення Iroh хоста як зрозумілу часову шкалу,\nті самі дані, що в Налаштування > Мережа > Звіт про підключення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux iroh-diag\n\n以易读的时间线形式输出主机的 Iroh 连接报告,\n数据与“设置 > 网络 > 连接报告”相同。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux iroh-diag\n\n以易讀的時間軸形式輸出主機的 Iroh 連線報告,\n資料與「設定 > 網路 > 連線報告」相同。" + } + } + } + }, + "cli.help.mosh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux mosh [flags] [-- ]\n\nنقل طرفية Mosh:\n --transport نقل الطرفية التفاعلية (الافتراضي: mosh)\n\nيدير SSH الميزات البعيدة، وينقل Mosh الطرفية التفاعلية فقط. إذا لم يتوفر Mosh محليًا أو عن بُعد، يوضّح cmux ذلك ويستخدم SSH.\n\nمثال:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upotreba: cmux mosh [flags] [-- ]\n\nMosh transport terminala:\n --transport Transport interaktivnog terminala (zadano: mosh)\n\nSSH upravlja udaljenim funkcijama, a Mosh prenosi samo terminal. Ako Mosh nedostaje lokalno ili udaljeno, cmux to prijavljuje i koristi SSH.\n\nPrimjer:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug: cmux mosh [flags] [-- ]\n\nMosh-terminaltransport:\n --transport Interaktiv terminaltransport (standard: mosh)\n\nSSH håndterer fjernfunktioner, og Mosh bærer kun terminalen. Hvis Mosh mangler lokalt eller på fjernværten, oplyser cmux det og bruger SSH.\n\nEksempel:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux mosh [flags] [-- ]\n\nMosh-Terminaltransport:\n --transport Interaktiver Terminaltransport (Standard: mosh)\n\nSSH verwaltet Remote-Funktionen, Mosh überträgt nur das Terminal. Fehlt Mosh lokal oder remote, meldet cmux dies und verwendet SSH.\n\nBeispiel:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux mosh [flags] [-- ]\n\nMosh terminal transport:\n --transport Interactive terminal transport (default: mosh)\n\nSSH continues to handle remote features; Mosh carries only the interactive\nterminal. If Mosh is missing locally or remotely, cmux reports it and uses SSH.\n\nExample:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux mosh [flags] [-- ]\n\nTransporte de terminal Mosh:\n --transport Transporte del terminal interactivo (predeterminado: mosh)\n\nSSH gestiona las funciones remotas y Mosh solo transporta el terminal. Si falta Mosh localmente o en remoto, cmux lo indica y usa SSH.\n\nEjemplo:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation: cmux mosh [flags] [-- ]\n\nTransport de terminal Mosh :\n --transport Transport du terminal interactif (par défaut : mosh)\n\nSSH gère les fonctions distantes et Mosh transporte seulement le terminal. Si Mosh manque en local ou à distance, cmux le signale et utilise SSH.\n\nExemple :\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Utilizzo: cmux mosh [flags] [-- ]\n\nTrasporto terminale Mosh:\n --transport Trasporto del terminale interattivo (predefinito: mosh)\n\nSSH gestisce le funzioni remote e Mosh trasporta solo il terminale. Se Mosh manca in locale o in remoto, cmux lo segnala e usa SSH.\n\nEsempio:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法: cmux mosh [flags] [-- ]\n\nMosh ターミナルトランスポート:\n --transport 対話型ターミナルのトランスポート(デフォルト: mosh)\n\nリモート機能は SSH が管理し、Mosh は対話型ターミナルだけを運びます。ローカルまたはリモートに Mosh がない場合、cmux はその旨を表示して SSH を使用します。\n\n例:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការប្រើប្រាស់: cmux mosh [flags] [-- ]\n\nមធ្យោបាយស្ថានីយ Mosh៖\n --transport មធ្យោបាយស្ថានីយអន្តរកម្ម (លំនាំដើម៖ mosh)\n\nSSH គ្រប់គ្រងមុខងារពីចម្ងាយ ហើយ Mosh ដឹកតែស្ថានីយ។ បើ Mosh មិនមាននៅម៉ាស៊ីនមូលដ្ឋាន ឬម៉ាស៊ីនពីចម្ងាយ cmux នឹងជូនដំណឹង ហើយប្រើ SSH។\n\nឧទាហរណ៍៖\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux mosh [flags] [-- ]\n\nMosh 터미널 전송 방식:\n --transport 대화형 터미널 전송 방식(기본값: mosh)\n\n원격 기능은 SSH가 관리하고 Mosh는 터미널만 전송합니다. 로컬 또는 원격에 Mosh가 없으면 cmux가 알리고 SSH를 사용합니다.\n\n예:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk: cmux mosh [flags] [-- ]\n\nMosh-terminaltransport:\n --transport Interaktiv terminaltransport (standard: mosh)\n\nSSH håndterer eksterne funksjoner, og Mosh bærer bare terminalen. Hvis Mosh mangler lokalt eller eksternt, sier cmux fra og bruker SSH.\n\nEksempel:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie: cmux mosh [flags] [-- ]\n\nTransport terminala Mosh:\n --transport Transport terminala interaktywnego (domyślnie: mosh)\n\nSSH obsługuje funkcje zdalne, a Mosh tylko terminal. Jeśli brakuje Mosh lokalnie lub zdalnie, cmux informuje o tym i używa SSH.\n\nPrzykład:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux mosh [flags] [-- ]\n\nTransporte de terminal Mosh:\n --transport Transporte do terminal interativo (padrão: mosh)\n\nO SSH gerencia os recursos remotos e o Mosh transporta apenas o terminal. Se faltar Mosh local ou remotamente, o cmux informa e usa SSH.\n\nExemplo:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование: cmux mosh [flags] [-- ]\n\nТранспорт терминала Mosh:\n --transport Транспорт интерактивного терминала (по умолчанию: mosh)\n\nSSH управляет удалёнными функциями, а Mosh передаёт только терминал. Если Mosh отсутствует локально или удалённо, cmux сообщает об этом и использует SSH.\n\nПример:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งาน: cmux mosh [flags] [-- ]\n\nการส่งเทอร์มินัล Mosh:\n --transport การส่งเทอร์มินัลแบบโต้ตอบ (ค่าเริ่มต้น: mosh)\n\nSSH จัดการคุณสมบัติระยะไกล ส่วน Mosh ส่งเฉพาะเทอร์มินัล หากไม่มี Mosh ในเครื่องหรือบนโฮสต์ระยะไกล cmux จะแจ้งและใช้ SSH\n\nตัวอย่าง:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım: cmux mosh [flags] [-- ]\n\nMosh terminal aktarımı:\n --transport Etkileşimli terminal aktarımı (varsayılan: mosh)\n\nUzak özellikleri SSH yönetir, Mosh yalnızca terminali taşır. Mosh yerelde veya uzakta yoksa cmux bunu bildirir ve SSH kullanır.\n\nÖrnek:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання: cmux mosh [flags] [-- ]\n\nТранспорт термінала Mosh:\n --transport Транспорт інтерактивного термінала (типово: mosh)\n\nSSH керує віддаленими функціями, а Mosh передає лише термінал. Якщо Mosh відсутній локально або віддалено, cmux повідомляє про це й використовує SSH.\n\nПриклад:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux mosh [flags] [-- ]\n\nMosh 终端传输:\n --transport 交互式终端传输方式(默认:mosh)\n\nSSH 管理远程功能,Mosh 仅承载终端。如果本地或远程缺少 Mosh,cmux 会说明原因并使用 SSH。\n\n示例:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux mosh [flags] [-- ]\n\nMosh 終端傳輸:\n --transport 互動式終端傳輸方式(預設:mosh)\n\nSSH 管理遠端功能,Mosh 只承載終端。如果本機或遠端缺少 Mosh,cmux 會說明原因並使用 SSH。\n\n範例:\n cmux ssh dev@my-host --transport mosh\n\n cmux mosh dev@my-host" + } + } + } + }, + "cli.help.mosh-tmux": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux mosh-tmux [--session ] [flags]\n\nأنشئ مساحة عمل Mosh تُنشئ جلسة tmux مسماة أو تتصل بها (الافتراضي: main). يستمر ملف تعريف tmux بعد إعادة الاتصال واستعادة جلسة التطبيق. هذه طرفية tmux متجولة وليست مرآة cmux ssh-tmux الأصلية. يحدد --session الجلسة، وتطابق بقية الخيارات cmux mosh. عند غياب Mosh يعمل ملف tmux نفسه عبر SSH.\n\nمثال:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upotreba: cmux mosh-tmux [--session ] [flags]\n\nKreirajte Mosh workspace koji kreira ili spaja imenovanu tmux sesiju (zadano: main). Tmux profil opstaje kroz ponovno povezivanje i vraćanje sesije aplikacije. Ovo je roaming tmux terminal, a ne izvorni mirror iz cmux ssh-tmux. --session bira sesiju; ostale opcije odgovaraju cmux mosh. Bez Mosha isti profil radi preko SSH-a.\n\nPrimjer:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug: cmux mosh-tmux [--session ] [flags]\n\nOpret et Mosh-arbejdsområde, der opretter eller tilslutter en navngivet tmux-session (standard: main). Tmux-profilen bevares ved genoprettelse og app-sessionsgendannelse. Dette er en roaming tmux-terminal, ikke det native mirror fra cmux ssh-tmux. --session vælger sessionen; øvrige flag matcher cmux mosh. Uden Mosh kører samme profil over SSH.\n\nEksempel:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux mosh-tmux [--session ] [flags]\n\nErstellt einen Mosh-Workspace, der eine benannte tmux-Sitzung erstellt oder anhängt (Standard: main). Das tmux-Profil bleibt bei Wiederverbindung und App-Sitzungswiederherstellung erhalten. Dies ist ein roamingfähiges tmux-Terminal, nicht der native Mirror von cmux ssh-tmux. --session wählt die Sitzung; alle übrigen Optionen entsprechen cmux mosh. Ohne Mosh läuft dasselbe Profil über SSH.\n\nBeispiel:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux mosh-tmux [--session ] [flags]\n\nCreate a Mosh workspace that creates or attaches to a named tmux session (default: main). The tmux profile persists across reconnect and app session restore. This is a roaming tmux terminal, not the native mirror from cmux ssh-tmux. --session selects the session; all other flags match cmux mosh. Without Mosh, the same profile runs over SSH.\n\nExample:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux mosh-tmux [--session ] [flags]\n\nCrea un espacio Mosh que crea o adjunta una sesión tmux con nombre (predeterminada: main). El perfil tmux persiste al reconectar y restaurar la sesión de la app. Es un terminal tmux itinerante, no el espejo nativo de cmux ssh-tmux. --session elige la sesión; las demás opciones coinciden con cmux mosh. Sin Mosh, el mismo perfil funciona por SSH.\n\nEjemplo:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation: cmux mosh-tmux [--session ] [flags]\n\nCrée un espace Mosh qui crée ou rejoint une session tmux nommée (par défaut : main). Le profil tmux persiste après reconnexion et restauration de session. Il s’agit d’un terminal tmux itinérant, pas du miroir natif de cmux ssh-tmux. --session choisit la session ; les autres options correspondent à cmux mosh. Sans Mosh, le même profil passe par SSH.\n\nExemple:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Utilizzo: cmux mosh-tmux [--session ] [flags]\n\nCrea un workspace Mosh che crea o collega una sessione tmux denominata (predefinita: main). Il profilo tmux persiste dopo riconnessione e ripristino della sessione dell’app. È un terminale tmux roaming, non il mirror nativo di cmux ssh-tmux. --session sceglie la sessione; le altre opzioni corrispondono a cmux mosh. Senza Mosh, lo stesso profilo usa SSH.\n\nEsempio:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法: cmux mosh-tmux [--session ] [flags]\n\n名前付き tmux セッションを作成またはアタッチする Mosh ワークスペースを作成します(デフォルト: main)。tmux プロファイルは再接続とアプリセッション復元後も保持されます。これはローミング可能な tmux ターミナルであり、cmux ssh-tmux のネイティブミラーではありません。--session でセッションを選び、その他のフラグは cmux mosh と同じです。Mosh がなければ同じプロファイルを SSH で実行します。\n\n例:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការប្រើប្រាស់: cmux mosh-tmux [--session ] [flags]\n\nបង្កើត Mosh workspace ដែលបង្កើត ឬភ្ជាប់ tmux session មានឈ្មោះ (លំនាំដើម៖ main)។ tmux profile ត្រូវបានរក្សាទុកពេល reconnect និង restore app session។ វាជា tmux terminal ដែល roam បាន មិនមែន native mirror របស់ cmux ssh-tmux ទេ។ --session ជ្រើស session; flags ផ្សេងទៀតដូច cmux mosh។ បើគ្មាន Mosh profile ដដែលដំណើរការតាម SSH។\n\nឧទាហរណ៍:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux mosh-tmux [--session ] [flags]\n\n이름 있는 tmux 세션을 만들거나 연결하는 Mosh 워크스페이스를 만듭니다(기본값: main). tmux 프로필은 재연결 및 앱 세션 복원 후에도 유지됩니다. 이는 로밍 tmux 터미널이며 cmux ssh-tmux의 네이티브 미러가 아닙니다. --session 으로 세션을 선택하며 나머지 플래그는 cmux mosh와 같습니다. Mosh가 없으면 같은 프로필을 SSH로 실행합니다.\n\n예:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk: cmux mosh-tmux [--session ] [flags]\n\nOpprett et Mosh-arbeidsområde som oppretter eller kobler til en navngitt tmux-økt (standard: main). Tmux-profilen bevares ved ny tilkobling og gjenoppretting av appøkten. Dette er en roaming tmux-terminal, ikke det innebygde speilet fra cmux ssh-tmux. --session velger økten; øvrige flagg samsvarer med cmux mosh. Uten Mosh kjører samme profil over SSH.\n\nEksempel:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie: cmux mosh-tmux [--session ] [flags]\n\nUtwórz workspace Mosh, który tworzy lub dołącza do nazwanej sesji tmux (domyślnie: main). Profil tmux jest zachowywany po ponownym połączeniu i odtworzeniu sesji aplikacji. To terminal tmux z roamingiem, a nie natywne lustro z cmux ssh-tmux. --session wybiera sesję; pozostałe flagi są takie jak w cmux mosh. Bez Mosh ten sam profil działa przez SSH.\n\nPrzykład:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux mosh-tmux [--session ] [flags]\n\nCrie um workspace Mosh que cria ou conecta uma sessão tmux nomeada (padrão: main). O perfil tmux persiste após reconexão e restauração da sessão do app. É um terminal tmux com roaming, não o espelho nativo de cmux ssh-tmux. --session escolhe a sessão; as demais opções correspondem a cmux mosh. Sem Mosh, o mesmo perfil roda por SSH.\n\nExemplo:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование: cmux mosh-tmux [--session ] [flags]\n\nСоздаёт Mosh-пространство, которое создаёт или подключает именованный сеанс tmux (по умолчанию: main). Профиль tmux сохраняется при переподключении и восстановлении сеанса приложения. Это роуминговый терминал tmux, а не нативное зеркало cmux ssh-tmux. --session выбирает сеанс; остальные параметры совпадают с cmux mosh. Без Mosh тот же профиль работает через SSH.\n\nПример:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งาน: cmux mosh-tmux [--session ] [flags]\n\nสร้าง Mosh workspace ที่สร้างหรือแนบ tmux session ที่มีชื่อ (ค่าเริ่มต้น: main) โปรไฟล์ tmux คงอยู่หลัง reconnect และ restore app session นี่คือ tmux terminal ที่ roaming ได้ ไม่ใช่ native mirror ของ cmux ssh-tmux ใช้ --session เลือก session; แฟล็กอื่นเหมือน cmux mosh หากไม่มี Mosh โปรไฟล์เดิมจะทำงานผ่าน SSH\n\nตัวอย่าง:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım: cmux mosh-tmux [--session ] [flags]\n\nAdlandırılmış bir tmux oturumu oluşturan veya ona bağlanan Mosh çalışma alanı oluşturur (varsayılan: main). tmux profili yeniden bağlanma ve uygulama oturumu geri yüklemesinde korunur. Bu, dolaşabilen bir tmux terminalidir; cmux ssh-tmux yerel aynası değildir. --session oturumu seçer; diğer bayraklar cmux mosh ile aynıdır. Mosh yoksa aynı profil SSH üzerinden çalışır.\n\nÖrnek:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання: cmux mosh-tmux [--session ] [flags]\n\nСтворює Mosh-простір, який створює або приєднує іменований сеанс tmux (типово: main). Профіль tmux зберігається після перепідключення й відновлення сеансу програми. Це роумінговий термінал tmux, а не нативне дзеркало cmux ssh-tmux. --session вибирає сеанс; решта параметрів відповідає cmux mosh. Без Mosh той самий профіль працює через SSH.\n\nПриклад:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux mosh-tmux [--session ] [flags]\n\n创建一个 Mosh 工作区,用于创建或附加命名的 tmux 会话(默认:main)。tmux 配置会在重连和应用会话恢复后保留。这是可漫游的 tmux 终端,而不是 cmux ssh-tmux 的原生镜像。--session 选择会话;其他选项与 cmux mosh 相同。没有 Mosh 时,同一配置会通过 SSH 运行。\n\n示例:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法: cmux mosh-tmux [--session ] [flags]\n\n建立 Mosh 工作區,用來建立或附加具名 tmux 工作階段(預設:main)。tmux 設定會在重新連線與 App 工作階段還原後保留。這是可漫遊的 tmux 終端,而不是 cmux ssh-tmux 的原生鏡像。--session 選擇工作階段;其他選項與 cmux mosh 相同。沒有 Mosh 時,同一設定會透過 SSH 執行。\n\n範例:\n cmux mosh-tmux dev@my-host\n cmux mosh-tmux dev@my-host --session agent-main" + } + } + } + }, + "cli.help.simulator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator [args] [--surface ]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator [args] [--surface ]" + } + } + } + }, + "cli.help.ssh.initialCommand": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأمر الأولي:\n --command شغّل نص الأمر مرة واحدة في الطرفية البعيدة الأولية بعد بدء الصدفة\n\nمثال:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Početna naredba:\n --command Pokrenite tekst naredbe jednom u početnom udaljenom terminalu nakon pokretanja ljuske\n\nPrimjer:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Indledende kommando:\n --command Kør kommandoteksten én gang i den første fjernterminal efter shell-start\n\nEksempel:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Initialer Befehl:\n --command Befehlstext nach dem Shell-Start einmal im ersten Remote-Terminal ausführen\n\nBeispiel:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Initial command:\n --command Run text once in the initial remote terminal after shell startup\n\nExample:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comando inicial:\n --command Ejecutar el texto del comando una vez en el terminal remoto inicial después de iniciar el shell\n\nEjemplo:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commande initiale :\n --command Exécuter le texte de la commande une seule fois dans le terminal distant initial après le démarrage du shell\n\nExemple :\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comando iniziale:\n --command Esegui il testo del comando una volta nel terminale remoto iniziale dopo l'avvio della shell\n\nEsempio:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "初期コマンド:\n --command シェル起動後、最初のリモートターミナルでテキストを一度だけ実行\n\n例:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ពាក្យបញ្ជាដំបូង៖\n --command ដំណើរការអត្ថបទពាក្យបញ្ជាម្តងនៅក្នុងស្ថានីយពីចម្ងាយដំបូង បន្ទាប់ពី shell ចាប់ផ្តើម\n\nឧទាហរណ៍៖\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "초기 명령:\n --command 셸 시작 후 첫 원격 터미널에서 명령 텍스트를 한 번만 실행\n\n예:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Første kommando:\n --command Kjør kommandoteksten én gang i den første eksterne terminalen etter skalloppstart\n\nEksempel:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Polecenie początkowe:\n --command Uruchom tekst polecenia jeden raz w początkowym zdalnym terminalu po starcie powłoki\n\nPrzykład:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Comando inicial:\n --command Execute o texto do comando uma vez no terminal remoto inicial após iniciar o shell\n\nExemplo:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Начальная команда:\n --command Выполнить текст команды один раз в исходном удалённом терминале после запуска оболочки\n\nПример:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำสั่งเริ่มต้น:\n --command เรียกใช้ข้อความคำสั่งหนึ่งครั้งในเทอร์มินัลระยะไกลเริ่มต้นหลังจากเชลล์เริ่มทำงาน\n\nตัวอย่าง:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İlk komut:\n --command Komut metnini kabuk başladıktan sonra ilk uzak terminalde bir kez çalıştırın\n\nÖrnek:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Початкова команда:\n --command Виконати текст команди один раз у початковому віддаленому терміналі після запуску оболонки\n\nПриклад:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "初始命令:\n --command 在 shell 启动后于初始远程终端中运行一次命令文本\n\n示例:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "初始命令:\n --command 在 shell 啟動後於初始遠端終端機中執行一次命令文字\n\n範例:\n cmux ssh dev@my-host --command 'omp \"investigate auth\"'" + } + } + } + }, + "cli.help.ssh.mosh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل طرفية Mosh:\n --transport نقل الطرفية التفاعلية (الافتراضي: ssh)\n\nيدير SSH الميزات البعيدة، وينقل Mosh الطرفية التفاعلية فقط. إذا لم يتوفر Mosh محليًا أو عن بُعد، يوضّح cmux ذلك ويستخدم SSH.\n\nمثال:\n cmux ssh dev@my-host --transport mosh" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Mosh transport terminala:\n --transport Transport interaktivnog terminala (zadano: ssh)\n\nSSH upravlja udaljenim funkcijama, a Mosh prenosi samo terminal. Ako Mosh nedostaje lokalno ili udaljeno, cmux to prijavljuje i koristi SSH.\n\nPrimjer:\n cmux ssh dev@my-host --transport mosh" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Mosh-terminaltransport:\n --transport Interaktiv terminaltransport (standard: ssh)\n\nSSH håndterer fjernfunktioner, og Mosh bærer kun terminalen. Hvis Mosh mangler lokalt eller på fjernværten, oplyser cmux det og bruger SSH.\n\nEksempel:\n cmux ssh dev@my-host --transport mosh" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mosh-Terminaltransport:\n --transport Interaktiver Terminaltransport (Standard: ssh)\n\nSSH verwaltet Remote-Funktionen, Mosh überträgt nur das Terminal. Fehlt Mosh lokal oder remote, meldet cmux dies und verwendet SSH.\n\nBeispiel:\n cmux ssh dev@my-host --transport mosh" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mosh terminal transport:\n --transport Interactive terminal transport (default: ssh)\n\nSSH continues to handle remote features; Mosh carries only the interactive\nterminal. If Mosh is missing locally or remotely, cmux reports it and uses SSH.\n\nExample:\n cmux ssh dev@my-host --transport mosh" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Transporte de terminal Mosh:\n --transport Transporte del terminal interactivo (predeterminado: ssh)\n\nSSH gestiona las funciones remotas y Mosh solo transporta el terminal. Si falta Mosh localmente o en remoto, cmux lo indica y usa SSH.\n\nEjemplo:\n cmux ssh dev@my-host --transport mosh" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Transport de terminal Mosh :\n --transport Transport du terminal interactif (par défaut : ssh)\n\nSSH gère les fonctions distantes et Mosh transporte seulement le terminal. Si Mosh manque en local ou à distance, cmux le signale et utilise SSH.\n\nExemple :\n cmux ssh dev@my-host --transport mosh" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trasporto terminale Mosh:\n --transport Trasporto del terminale interattivo (predefinito: ssh)\n\nSSH gestisce le funzioni remote e Mosh trasporta solo il terminale. Se Mosh manca in locale o in remoto, cmux lo segnala e usa SSH.\n\nEsempio:\n cmux ssh dev@my-host --transport mosh" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Mosh ターミナルトランスポート:\n --transport 対話型ターミナルのトランスポート(デフォルト: ssh)\n\nリモート機能は SSH が管理し、Mosh は対話型ターミナルだけを運びます。ローカルまたはリモートに Mosh がない場合、cmux はその旨を表示して SSH を使用します。\n\n例:\n cmux ssh dev@my-host --transport mosh" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មធ្យោបាយស្ថានីយ Mosh៖\n --transport មធ្យោបាយស្ថានីយអន្តរកម្ម (លំនាំដើម៖ ssh)\n\nSSH គ្រប់គ្រងមុខងារពីចម្ងាយ ហើយ Mosh ដឹកតែស្ថានីយ។ បើខ្វះ Mosh cmux នឹងជូនដំណឹង ហើយប្រើ SSH។\n\nឧទាហរណ៍៖\n cmux ssh dev@my-host --transport mosh" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Mosh 터미널 전송 방식:\n --transport 대화형 터미널 전송 방식(기본값: ssh)\n\n원격 기능은 SSH가 관리하고 Mosh는 터미널만 전송합니다. 로컬 또는 원격에 Mosh가 없으면 cmux가 알리고 SSH를 사용합니다.\n\n예:\n cmux ssh dev@my-host --transport mosh" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Mosh-terminaltransport:\n --transport Interaktiv terminaltransport (standard: ssh)\n\nSSH håndterer eksterne funksjoner, og Mosh bærer bare terminalen. Hvis Mosh mangler lokalt eller eksternt, sier cmux fra og bruker SSH.\n\nEksempel:\n cmux ssh dev@my-host --transport mosh" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Transport terminala Mosh:\n --transport Transport terminala interaktywnego (domyślnie: ssh)\n\nSSH obsługuje funkcje zdalne, a Mosh tylko terminal. Jeśli brakuje Mosh lokalnie lub zdalnie, cmux informuje o tym i używa SSH.\n\nPrzykład:\n cmux ssh dev@my-host --transport mosh" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Transporte de terminal Mosh:\n --transport Transporte do terminal interativo (padrão: ssh)\n\nO SSH gerencia os recursos remotos e o Mosh transporta apenas o terminal. Se faltar Mosh local ou remotamente, o cmux informa e usa SSH.\n\nExemplo:\n cmux ssh dev@my-host --transport mosh" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Транспорт терминала Mosh:\n --transport Транспорт интерактивного терминала (по умолчанию: ssh)\n\nSSH управляет удалёнными функциями, а Mosh передаёт только терминал. Если Mosh отсутствует локально или удалённо, cmux сообщает об этом и использует SSH.\n\nПример:\n cmux ssh dev@my-host --transport mosh" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การส่งเทอร์มินัล Mosh:\n --transport การส่งเทอร์มินัลแบบโต้ตอบ (ค่าเริ่มต้น: ssh)\n\nSSH จัดการคุณสมบัติระยะไกล ส่วน Mosh ส่งเฉพาะเทอร์มินัล หากไม่มี Mosh cmux จะแจ้งและใช้ SSH\n\nตัวอย่าง:\n cmux ssh dev@my-host --transport mosh" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Mosh terminal aktarımı:\n --transport Etkileşimli terminal aktarımı (varsayılan: ssh)\n\nUzak özellikleri SSH yönetir, Mosh yalnızca terminali taşır. Mosh yerelde veya uzakta yoksa cmux bunu bildirir ve SSH kullanır.\n\nÖrnek:\n cmux ssh dev@my-host --transport mosh" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Транспорт термінала Mosh:\n --transport Транспорт інтерактивного термінала (типово: ssh)\n\nSSH керує віддаленими функціями, а Mosh передає лише термінал. Якщо Mosh відсутній локально або віддалено, cmux повідомляє про це й використовує SSH.\n\nПриклад:\n cmux ssh dev@my-host --transport mosh" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Mosh 终端传输:\n --transport 交互式终端传输方式(默认:ssh)\n\nSSH 管理远程功能,Mosh 仅承载终端。如果本地或远程缺少 Mosh,cmux 会说明原因并使用 SSH。\n\n示例:\n cmux ssh dev@my-host --transport mosh" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Mosh 終端傳輸:\n --transport 互動式終端傳輸方式(預設:ssh)\n\nSSH 管理遠端功能,Mosh 只承載終端。如果本機或遠端缺少 Mosh,cmux 會說明原因並使用 SSH。\n\n範例:\n cmux ssh dev@my-host --transport mosh" + } + } + } + }, + "cli.hooks.error.configDirectoryCreateFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على cmux إنشاء دليل hooks في %@. تحقّق من أذونات الدليل الأصل ثم أعد المحاولة." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao kreirati direktorij hookova na %@. Provjerite dozvole nadređenog direktorija i pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke oprette hookmappen på %@. Kontrollér tilladelserne for den overordnede mappe, og prøv igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte das Hook-Verzeichnis unter %@ nicht erstellen. Prüfe die Berechtigungen des übergeordneten Verzeichnisses und versuche es erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not create the hooks directory at %@. Check the parent directory permissions and try again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo crear el directorio de hooks en %@. Comprueba los permisos del directorio superior e inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n’a pas pu créer le dossier des hooks à l’emplacement %@. Vérifiez les autorisations du dossier parent et réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a creare la directory degli hook in %@. Controlla i permessi della directory principale e riprova." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux は %@ に hooks ディレクトリを作成できませんでした。親ディレクトリの権限を確認して、もう一度お試しください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux មិនអាចបង្កើតថត hooks នៅ %@ បានទេ។ ពិនិត្យសិទ្ធិរបស់ថតមេ ហើយព្យាយាមម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 %@에 hooks 디렉터리를 만들지 못했습니다. 상위 디렉터리 권한을 확인한 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke opprette hookmappen på %@. Kontroller tillatelsene for den overordnede mappen og prøv på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł utworzyć katalogu hooków w %@. Sprawdź uprawnienia katalogu nadrzędnego i spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu criar o diretório de hooks em %@. Verifique as permissões do diretório pai e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось создать каталог хуков по пути %@. Проверьте разрешения родительского каталога и повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถสร้างไดเรกทอรี hooks ที่ %@ ได้ โปรดตรวจสอบสิทธิ์ของไดเรกทอรีหลักแล้วลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux %@ konumunda hooks dizinini oluşturamadı. Üst dizinin izinlerini kontrol edip yeniden deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux не вдалося створити каталог хуків за шляхом %@. Перевірте дозволи батьківського каталогу та повторіть спробу." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法在 %@ 创建 hooks 目录。请检查父目录权限,然后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法在 %@ 建立 hooks 目錄。請檢查上層目錄權限,然後再試一次。" + } + } + } + }, + "cli.hooks.error.pinnedTargetMissing": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر على cmux ربط تثبيت الـ hooks هذا بتطبيق قيد التشغيل. افتح مساحة عمل في cmux وشغّل هذا الأمر مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "cmux nije mogao povezati ovu instalaciju hookova s pokrenutom aplikacijom. Otvorite cmux radni prostor i ponovo pokrenite ovu naredbu." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke forbinde denne hookinstallation med en kørende app. Åbn et cmux-arbejdsområde, og kør kommandoen igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "cmux konnte diese Hook-Installation nicht mit einer laufenden App verbinden. Öffne einen cmux-Arbeitsbereich und führe diesen Befehl erneut aus." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux could not connect this hook installation to a running app. Open a cmux workspace and run this command again." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "cmux no pudo conectar esta instalación de hooks con una aplicación en ejecución. Abre un espacio de trabajo de cmux y vuelve a ejecutar este comando." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "cmux n’a pas pu relier cette installation de hooks à une application en cours d’exécution. Ouvrez un espace de travail cmux et relancez cette commande." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "cmux non è riuscito a collegare questa installazione di hook a un’app in esecuzione. Apri uno spazio di lavoro cmux ed esegui di nuovo questo comando." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux はこの hook のインストールを実行中のアプリに接続できませんでした。cmux ワークスペースを開き、このコマンドをもう一度実行してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "cmux មិនអាចភ្ជាប់ការដំឡើង hooks នេះទៅកម្មវិធីដែលកំពុងដំណើរការបានទេ។ បើក workspace ក្នុង cmux ហើយដំណើរការពាក្យបញ្ជានេះម្តងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux가 이 훅 설치를 실행 중인 앱에 연결하지 못했습니다. cmux 작업 공간을 열고 이 명령을 다시 실행하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "cmux kunne ikke koble denne hookinstallasjonen til en app som kjører. Åpne et cmux-arbeidsområde og kjør kommandoen på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "cmux nie mógł połączyć tej instalacji hooków z uruchomioną aplikacją. Otwórz obszar roboczy cmux i uruchom to polecenie ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O cmux não conseguiu conectar esta instalação de hooks a um aplicativo em execução. Abra um espaço de trabalho do cmux e execute este comando novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "cmux не удалось связать эту установку хуков с запущенным приложением. Откройте рабочее пространство cmux и выполните эту команду снова." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "cmux ไม่สามารถเชื่อมต่อการติดตั้ง hooks นี้กับแอปที่กำลังทำงานอยู่ได้ เปิดพื้นที่ทำงาน cmux แล้วเรียกใช้คำสั่งนี้อีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux bu hook kurulumunu çalışan bir uygulamaya bağlayamadı. Bir cmux çalışma alanı açın ve bu komutu yeniden çalıştırın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "cmux не вдалося пов’язати це встановлення хуків із запущеною програмою. Відкрийте робочий простір cmux і виконайте цю команду ще раз." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "cmux 无法将此 hooks 安装连接到正在运行的应用。请打开一个 cmux 工作区,然后再次运行此命令。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "cmux 無法將此 hooks 安裝連接到執行中的應用程式。請開啟 cmux 工作區,然後再次執行此命令。" + } + } + } + }, + "cli.hooks.kimi.secondaryRefreshWarning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحذير: تم تثبيت خطافات cmux في %@، لكن تعذّر على cmux تحديث كتلة الخطافات الموجودة في %@. تحقّق من هذا المسار وأعد تشغيل `cmux hooks setup kimi` لإكمال التحديث." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upozorenje: cmux hookovi su instalirani u %@, ali cmux nije mogao osvježiti postojeći blok hookova u %@. Provjerite tu putanju i ponovo pokrenite `cmux hooks setup kimi` da dovršite ažuriranje." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Advarsel: cmux-hooks er installeret i %@, men cmux kunne ikke opdatere sin eksisterende hook-blok i %@. Tjek den sti, og kør `cmux hooks setup kimi` igen for at fuldføre opdateringen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warnung: cmux-Hooks sind in %@ installiert, aber cmux konnte den vorhandenen Hook-Block in %@ nicht aktualisieren. Prüfe diesen Pfad und führe `cmux hooks setup kimi` erneut aus, um die Aktualisierung abzuschließen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning: cmux hooks are installed at %@, but cmux could not refresh its existing hook block in %@. Check that path and re-run `cmux hooks setup kimi` to finish the update." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Advertencia: los hooks de cmux están instalados en %@, pero cmux no pudo actualizar su bloque de hooks existente en %@. Revisa esa ruta y vuelve a ejecutar `cmux hooks setup kimi` para completar la actualización." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Avertissement : les hooks cmux sont installés dans %@, mais cmux n'a pas pu actualiser son bloc de hooks existant dans %@. Vérifiez ce chemin et relancez `cmux hooks setup kimi` pour terminer la mise à jour." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avviso: gli hook di cmux sono installati in %@, ma cmux non è riuscito ad aggiornare il blocco di hook esistente in %@. Controlla quel percorso ed esegui di nuovo `cmux hooks setup kimi` per completare l'aggiornamento." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告: cmux hooks は %@ にインストールされましたが、%@ にある既存の hook ブロックを更新できませんでした。そのパスを確認し、`cmux hooks setup kimi` を再実行して更新を完了してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការព្រមាន៖ cmux hooks ត្រូវបានដំឡើងនៅ %@ ប៉ុន្តែ cmux មិនអាចធ្វើបច្ចុប្បន្នភាពប្លុក hook ដែលមានស្រាប់នៅក្នុង %@ បានទេ។ សូមពិនិត្យផ្លូវនោះ រួចដំណើរការ `cmux hooks setup kimi` ម្ដងទៀត ដើម្បីបញ្ចប់ការធ្វើបច្ចុប្បន្នភាព។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경고: cmux hooks가 %@에 설치되었지만 %@에 있는 기존 hook 블록을 갱신하지 못했습니다. 해당 경로를 확인한 뒤 `cmux hooks setup kimi`를 다시 실행해 업데이트를 완료하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Advarsel: cmux-hooks er installert i %@, men cmux kunne ikke oppdatere den eksisterende hook-blokken i %@. Sjekk den banen og kjør `cmux hooks setup kimi` på nytt for å fullføre oppdateringen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostrzeżenie: hooki cmux są zainstalowane w %@, ale cmux nie mógł odświeżyć istniejącego bloku hooków w %@. Sprawdź tę ścieżkę i uruchom ponownie `cmux hooks setup kimi`, aby dokończyć aktualizację." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aviso: os hooks do cmux estão instalados em %@, mas o cmux não conseguiu atualizar o bloco de hooks existente em %@. Verifique esse caminho e execute `cmux hooks setup kimi` novamente para concluir a atualização." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предупреждение: хуки cmux установлены в %@, но cmux не смог обновить существующий блок хуков в %@. Проверьте этот путь и запустите `cmux hooks setup kimi` ещё раз, чтобы завершить обновление." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำเตือน: ติดตั้ง cmux hooks ไว้ที่ %@ แล้ว แต่ cmux ไม่สามารถอัปเดตบล็อก hook ที่มีอยู่ใน %@ ได้ ตรวจสอบพาธนั้นแล้วรัน `cmux hooks setup kimi` อีกครั้งเพื่อทำการอัปเดตให้เสร็จสมบูรณ์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uyarı: cmux hook'ları %@ konumuna kuruldu ancak cmux, %@ içindeki mevcut hook bloğunu güncelleyemedi. Bu yolu kontrol edin ve güncellemeyi tamamlamak için `cmux hooks setup kimi` komutunu yeniden çalıştırın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Попередження: хуки cmux встановлено в %@, але cmux не зміг оновити наявний блок хуків у %@. Перевірте цей шлях і запустіть `cmux hooks setup kimi` ще раз, щоб завершити оновлення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "警告:cmux hooks 已安装到 %@,但 cmux 无法刷新 %@ 中已有的 hook 块。请检查该路径,然后重新运行 `cmux hooks setup kimi` 以完成更新。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "警告:cmux hooks 已安裝到 %@,但 cmux 無法更新 %@ 中既有的 hook 區塊。請檢查該路徑,然後重新執行 `cmux hooks setup kimi` 以完成更新。" + } + } + } + }, + "cli.hooks.kimi.secondaryUninstallWarning": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحذير: تعذّر على cmux إزالة كتلة الخطافات من %@. تحقّق من هذا المسار وأعد تشغيل `cmux hooks uninstall kimi` لإكمال التنظيف." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upozorenje: cmux nije mogao ukloniti svoj blok hookova iz %@. Provjerite tu putanju i ponovo pokrenite `cmux hooks uninstall kimi` da dovršite čišćenje." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Advarsel: cmux kunne ikke fjerne sin hook-blok fra %@. Tjek den sti, og kør `cmux hooks uninstall kimi` igen for at fuldføre oprydningen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Warnung: cmux konnte seinen Hook-Block nicht aus %@ entfernen. Prüfe diesen Pfad und führe `cmux hooks uninstall kimi` erneut aus, um die Bereinigung abzuschließen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning: cmux could not remove its hook block from %@. Check that path and re-run `cmux hooks uninstall kimi` to finish cleanup." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Advertencia: cmux no pudo eliminar su bloque de hooks de %@. Revisa esa ruta y vuelve a ejecutar `cmux hooks uninstall kimi` para completar la limpieza." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Avertissement : cmux n'a pas pu supprimer son bloc de hooks de %@. Vérifiez ce chemin et relancez `cmux hooks uninstall kimi` pour terminer le nettoyage." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avviso: cmux non è riuscito a rimuovere il proprio blocco di hook da %@. Controlla quel percorso ed esegui di nuovo `cmux hooks uninstall kimi` per completare la pulizia." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "警告: hook ブロックを %@ から削除できませんでした。そのパスを確認し、`cmux hooks uninstall kimi` を再実行してクリーンアップを完了してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការព្រមាន៖ cmux មិនអាចលុបប្លុក hook របស់ខ្លួនចេញពី %@ បានទេ។ សូមពិនិត្យផ្លូវនោះ រួចដំណើរការ `cmux hooks uninstall kimi` ម្ដងទៀត ដើម្បីបញ្ចប់ការសម្អាត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "경고: %@에서 hook 블록을 제거하지 못했습니다. 해당 경로를 확인한 뒤 `cmux hooks uninstall kimi`를 다시 실행해 정리를 완료하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Advarsel: cmux kunne ikke fjerne hook-blokken sin fra %@. Sjekk den banen og kjør `cmux hooks uninstall kimi` på nytt for å fullføre oppryddingen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ostrzeżenie: cmux nie mógł usunąć swojego bloku hooków z %@. Sprawdź tę ścieżkę i uruchom ponownie `cmux hooks uninstall kimi`, aby dokończyć czyszczenie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aviso: o cmux não conseguiu remover seu bloco de hooks de %@. Verifique esse caminho e execute `cmux hooks uninstall kimi` novamente para concluir a limpeza." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предупреждение: cmux не смог удалить свой блок хуков из %@. Проверьте этот путь и запустите `cmux hooks uninstall kimi` ещё раз, чтобы завершить очистку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำเตือน: cmux ไม่สามารถลบบล็อก hook ออกจาก %@ ได้ ตรวจสอบพาธนั้นแล้วรัน `cmux hooks uninstall kimi` อีกครั้งเพื่อทำความสะอาดให้เสร็จสมบูรณ์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Uyarı: cmux, hook bloğunu %@ konumundan kaldıramadı. Bu yolu kontrol edin ve temizliği tamamlamak için `cmux hooks uninstall kimi` komutunu yeniden çalıştırın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Попередження: cmux не зміг видалити свій блок хуків із %@. Перевірте цей шлях і запустіть `cmux hooks uninstall kimi` ще раз, щоб завершити очищення." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "警告:cmux 无法从 %@ 中移除其 hook 块。请检查该路径,然后重新运行 `cmux hooks uninstall kimi` 以完成清理。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "警告:cmux 無法從 %@ 移除其 hook 區塊。請檢查該路徑,然後重新執行 `cmux hooks uninstall kimi` 以完成清理。" + } + } + } + }, + "cli.hooks.pi.error.feedIngestionNotAcknowledged": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux did not receive acknowledgment for Pi feed ingestion" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux が Pi フィードの取り込み確認を受信できませんでした" + } + } + } + }, + "cli.ios.error.ambiguousTargets": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Found %lld iOS Simulator panes; pass --surface or --all" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld個のiOS Simulatorペインが見つかりました。--surface または --all を指定してください" + } + } + } + }, + "cli.ios.error.deviceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The requested iOS Simulator device was not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指定したiOS Simulatorデバイスが見つかりませんでした" + } + } + } + }, + "cli.ios.error.deviceSelectionFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The requested iOS Simulator device did not start streaming" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "指定したiOS Simulatorデバイスのストリーミングを開始できませんでした" + } + } + } + }, + "cli.ios.error.missingSimulatorID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected iOS pane has no Simulator identifier" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したiOSペインにSimulator識別子がありません" + } + } + } + }, + "cli.ios.error.missingSurfaceReference": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected iOS pane has no surface reference" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したiOSペインにサーフェス参照がありません" + } + } + } + }, + "cli.ios.error.noTargets": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No matching iOS Simulator panes were found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一致するiOS Simulatorペインが見つかりませんでした" + } + } + } + }, + "cli.ios.error.outputDirectoryRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--out must name an existing directory when capturing multiple Simulators" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "複数のSimulatorをキャプチャする場合、--outには既存のディレクトリを指定してください" + } + } + } + }, + "cli.ios.error.outputFileRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--out must name a file when capturing one Simulator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1台のSimulatorをキャプチャする場合、--outにはファイルを指定してください" + } + } + } + }, + "cli.ios.error.screenshotBatchLimit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Found %lld iOS Simulator panes; screenshot --all supports at most 8" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld個のiOS Simulatorペインが見つかりました。screenshot --allは最大8個まで対応しています" + } + } + } + }, + "cli.ios.error.screenshotBatchTimeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The iOS Simulator screenshot batch exceeded its 10-minute deadline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iOS Simulatorスクリーンショットの一括処理が10分の期限を超えました" + } + } + } + }, + "cli.ios.error.screenshotFailures": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One or more iOS Simulator screenshots failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1つ以上のiOS Simulatorスクリーンショットに失敗しました" + } + } + } + }, + "cli.ios.output.noTargets": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No iOS Simulator panes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iOS Simulatorペインがありません" + } + } + } + }, + "cli.ios.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux ios [args] [--surface ]\n\nEvery native `cmux simulator` subcommand is accepted unchanged.\n\nAdditional subcommands:\n list [--workspace ] List Simulator panes and device identifiers\n context [--udid] Print one selected Simulator identity\n select Bind a pane to an iPhone or iPad Simulator\n screenshot [--out ] Capture one or many selected Simulators\n\nExamples:\n cmux ios list --json\n cmux ios screenshot --surface surface:2 --out phone.png\n cmux ios screenshot --all --out screenshots/\n cmux ios rotate landscape-left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法: cmux ios [args] [--surface ]\n\nすべてのネイティブ `cmux simulator` サブコマンドをそのまま使用できます。\n\n追加サブコマンド:\n list [--workspace ] Simulatorペインとデバイス識別子を表示\n context [--udid] 選択したSimulator識別情報を表示\n select ペインをiPhoneまたはiPad Simulatorに接続\n screenshot [--out ] 選択したSimulatorをキャプチャ\n\n例:\n cmux ios list --json\n cmux ios screenshot --surface surface:2 --out phone.png\n cmux ios screenshot --all --out screenshots/\n cmux ios rotate landscape-left" + } + } + } + }, + "cli.moshTmux.arguments.invalid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يقبل mosh-tmux وجهة و --session، وليس أمرًا بعيدًا منفصلًا" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux prihvata odredište i --session, a ne zasebnu udaljenu naredbu" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux accepterer en destination og --session, ikke en separat fjernkommando" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux akzeptiert ein Ziel und --session, aber keinen separaten Remote-Befehl" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux accepts a destination and --session, not a separate remote command" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux acepta un destino y --session, no un comando remoto separado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux accepte une destination et --session, pas une commande distante distincte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux accetta una destinazione e --session, non un comando remoto separato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux は接続先と --session を受け付けます。別のリモートコマンドは指定できません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux ទទួល destination និង --session មិនទទួល remote command ដាច់ដោយឡែកទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux는 대상과 --session을 받으며 별도의 원격 명령은 받지 않습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux godtar et mål og --session, ikke en separat ekstern kommando" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux przyjmuje cel i --session, a nie osobne zdalne polecenie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux aceita um destino e --session, não um comando remoto separado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux принимает адрес и --session, но не отдельную удалённую команду" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux รับ destination และ --session ไม่รับ remote command แยกต่างหาก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux bir hedef ve --session kabul eder, ayrı bir uzak komut kabul etmez" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux приймає адресу та --session, а не окрему віддалену команду" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux 接受目标和 --session,不接受单独的远程命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux 接受目的地與 --session,不接受獨立的遠端命令" + } + } + } + }, + "cli.moshTmux.session.invalid": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: يتطلب --session اسم جلسة غير فارغ من دون محارف مخفية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session zahtijeva neprazno ime sesije bez skrivenih znakova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session kræver et ikke-tomt sessionsnavn uden skjulte tegn" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session erfordert einen nicht leeren Sitzungsnamen ohne versteckte Zeichen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session requires a non-empty session name without hidden characters" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session requiere un nombre de sesión no vacío y sin caracteres ocultos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux : --session exige un nom de session non vide sans caractères masqués" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session richiede un nome di sessione non vuoto e senza caratteri nascosti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session には、隠し文字を含まない空でないセッション名が必要です" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session ត្រូវការឈ្មោះ session មិនទទេ និងគ្មានតួអក្សរលាក់" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session에는 숨은 문자가 없는 비어 있지 않은 세션 이름이 필요합니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session krever et ikke-tomt øktnavn uten skjulte tegn" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session wymaga niepustej nazwy sesji bez ukrytych znaków" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session exige um nome de sessão não vazio e sem caracteres ocultos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session требует непустое имя сеанса без скрытых символов" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session ต้องมีชื่อ session ที่ไม่ว่างและไม่มีอักขระซ่อน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session gizli karakter içermeyen, boş olmayan bir oturum adı gerektirir" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux: --session потребує непорожньої назви сеансу без прихованих символів" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux:--session 需要一个不含隐藏字符的非空会话名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "mosh-tmux:--session 需要不含隱藏字元的非空工作階段名稱" + } + } + } + }, + "cli.newPane.help.profileDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "--profile Browser profile name or UUID" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "--profile <名前|UUID> ブラウザープロファイル名または UUID" + } + } + } + }, + "cli.restore.error.checkpointMismatch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このコマンドは現在のセッションと一致しません。現在の状態を使用するには 'cmux restore --surface' を実行してください。" + } + } + } + }, + "cli.restore.error.compatibilityShellFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: the saved process could not be started. Retry the visible restore command." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: 保存されたプロセスを開始できませんでした。表示されている復元コマンドを再実行してください。" + } + } + } + }, + "cli.restore.error.currentSurfaceUnknown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: the current cmux surface could not be identified. Retry from this terminal or pass --surface ." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: 現在の cmux サーフェスを特定できませんでした。このターミナルから再試行するか、--surface を指定してください。" + } + } + } + }, + "cli.restore.error.executableNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: the saved agent command is unavailable. Make sure the agent is installed, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: 保存されたエージェントコマンドを利用できません。エージェントがインストールされていることを確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.execveFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: the saved process could not be started. Retry the visible restore command." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: 保存されたプロセスを開始できませんでした。表示されている復元コマンドを再実行してください。" + } + } + } + }, + "cli.restore.error.incompleteData": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" + } + } + } + }, + "cli.restore.error.kindMismatch": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このコマンドは現在のセッションと一致しません。現在の状態を使用するには 'cmux restore --surface' を実行してください。" + } + } + } + }, + "cli.restore.error.malformedArguments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" + } + } + } + }, + "cli.restore.error.malformedRecord": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" + } + } + } + }, + "cli.restore.error.noRecord": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this session has nothing to restore. Start the agent again in this terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このセッションには復元できるものがありません。このターミナルでエージェントをもう一度起動してください。" + } + } + } + }, + "cli.restore.error.providerSetupConfigurationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup could not start. Check the agent's provider settings, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定を開始できませんでした。エージェントのプロバイダー設定を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.providerSetupExited": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup failed. Check the agent's provider settings, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定に失敗しました。エージェントのプロバイダー設定を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.providerSetupSignaled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup failed. Check the agent's provider settings, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定に失敗しました。エージェントのプロバイダー設定を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.providerSetupStartFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup could not start. Check the agent's provider settings, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定を開始できませんでした。エージェントのプロバイダー設定を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.providerSetupTimedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup took too long. Check the provider connection, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定に時間がかかりすぎました。プロバイダー接続を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.providerSetupUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup is unavailable. Check the agent's provider settings, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定を利用できません。エージェントのプロバイダー設定を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.providerSetupWaitFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: provider setup could not complete. Retry the visible restore command." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: プロバイダー設定を完了できませんでした。表示されている復元コマンドを再実行してください。" + } + } + } + }, + "cli.restore.error.socketNotReady": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: cmux is still opening. Retry the visible restore command in a moment." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: cmux はまだ起動中です。しばらくしてから、表示されている復元コマンドを再実行してください。" + } + } + } + }, + "cli.restore.error.surfaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: the requested surface was not found. Check the surface reference, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: 指定されたサーフェスが見つかりません。サーフェスの参照を確認してから、再試行してください。" + } + } + } + }, + "cli.restore.error.unsupportedMode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: this session's saved restore data is not compatible. Start the agent again in this terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: このセッションの保存済み復元データには互換性がありません。このターミナルでエージェントをもう一度起動してください。" + } + } + } + }, + "cli.restore.error.workingDirectoryFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "restore: the saved working directory is inaccessible. Restore access to it, then retry." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore: 保存された作業ディレクトリにアクセスできません。アクセスを復元してから、再試行してください。" + } + } + } + }, + "cli.help.restore": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "restore [--surface ] | restore --surface [id|ref]" + } + } + } + }, + "cli.restore.help": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nاستبدل عملية CLI هذه بعملية السطح المحفوظة. تحفظ السجلات الجديدة وسيطات التشغيل وcwd\nكقيم منظّمة؛ وتستخدم السجلات التي تحتوي على أمر فقط من الإصدارات الأقدم غلافًا متوافقًا.\nعند عدم تحديد id أو ref، يستخدم --surface سطح cmux الذي استدعى الأمر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upotreba: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nZamijeni ovaj CLI proces sačuvanim procesom površine. Novi zapisi čuvaju argumente\npokretanja i cwd kao strukturirane vrijednosti; zapisi samo s naredbom iz starijih\nverzija koriste kompatibilnu ljusku. Ako id ili ref nisu navedeni, --surface koristi\ncmux površinu koja je pozvala naredbu." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nErstat denne CLI-proces med den gemte overfladeproces. Nye poster bevarer\nstartargumenter og cwd som strukturerede værdier; poster fra ældre builds, der kun\nindeholder en kommando, bruger en kompatibilitetsskal. Uden id eller ref bruger\n--surface den kaldende cmux-overflade." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nErsetzt diesen CLI-Prozess durch den gespeicherten Surface-Prozess. Neue Datensätze\nbewahren Startargumente und cwd als strukturierte Werte auf; reine Befehlsdatensätze\nälterer Builds verwenden eine Kompatibilitäts-Shell. Ohne id oder ref verwendet\n--surface die aufrufende cmux-Surface." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nReplace this CLI process with the persisted surface process. New\nrecords preserve launch arguments and cwd as structured values;\ncommand-only records from older builds use a compatibility shell.\nWith no id or ref, --surface uses the calling cmux surface." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nReemplaza este proceso de CLI por el proceso de superficie guardado. Los registros\nnuevos conservan los argumentos de inicio y cwd como valores estructurados; los\nregistros de compilaciones anteriores que solo contienen un comando usan un shell de\ncompatibilidad. Sin id ni ref, --surface usa la superficie de cmux que realizó la llamada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nRemplace ce processus CLI par le processus de surface enregistré. Les nouveaux\nenregistrements conservent les arguments de lancement et cwd sous forme de valeurs\nstructurées ; les enregistrements des anciennes versions contenant uniquement une\ncommande utilisent un shell de compatibilité. Sans id ni ref, --surface utilise la\nsurface cmux appelante." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nSostituisce questo processo CLI con il processo della superficie salvato. I nuovi\nrecord conservano gli argomenti di avvio e cwd come valori strutturati; i record delle\nversioni precedenti contenenti solo un comando usano una shell di compatibilità. Senza\nid o ref, --surface usa la superficie cmux chiamante." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用法: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nこの CLI プロセスを保存されたサーフェスプロセスで置き換えます。新しい\nレコードでは起動引数と cwd が構造化された値として保持されます。\n旧ビルドのコマンドのみのレコードでは互換シェルを使用します。\nid または ref を省略すると、--surface は呼び出し元の cmux サーフェスを使用します。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការប្រើប្រាស់៖ cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nជំនួសដំណើរការ CLI នេះដោយដំណើរការផ្ទៃដែលបានរក្សាទុក។ កំណត់ត្រាថ្មីរក្សាទុកអាគុយម៉ង់ចាប់ផ្តើម\nនិង cwd ជាតម្លៃមានរចនាសម្ព័ន្ធ; កំណត់ត្រាដែលមានតែពាក្យបញ្ជាពី build ចាស់ប្រើ shell\nសម្រាប់ភាពត្រូវគ្នា។ បើមិនបញ្ជាក់ id ឬ ref ទេ --surface ប្រើផ្ទៃ cmux ដែលហៅពាក្យបញ្ជា។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\n이 CLI 프로세스를 저장된 서피스 프로세스로 교체합니다. 새 레코드는 실행 인수와 cwd를\n구조화된 값으로 보존하며, 이전 빌드의 명령 전용 레코드는 호환 셸을 사용합니다. id 또는\nref를 생략하면 --surface는 호출한 cmux 서피스를 사용합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nErstatt denne CLI-prosessen med den lagrede overflateprosessen. Nye oppføringer bevarer\nstartargumenter og cwd som strukturerte verdier; oppføringer fra eldre bygg som bare\ninneholder en kommando, bruker et kompatibilitetsskall. Uten id eller ref bruker\n--surface den kallende cmux-overflaten." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nZastępuje ten proces CLI zapisanym procesem powierzchni. Nowe rekordy zachowują\nargumenty uruchomienia i cwd jako wartości strukturalne; rekordy ze starszych\nkompilacji zawierające tylko polecenie używają powłoki zgodności. Bez id ani ref opcja\n--surface używa wywołującej powierzchni cmux." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nSubstitui este processo da CLI pelo processo de superfície persistido. Novos registros\npreservam os argumentos de inicialização e cwd como valores estruturados; registros de\nbuilds anteriores que contêm apenas um comando usam um shell de compatibilidade. Sem id\nou ref, --surface usa a superfície do cmux que fez a chamada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nЗаменяет этот процесс CLI сохранённым процессом поверхности. Новые записи сохраняют\nаргументы запуска и cwd как структурированные значения; записи старых сборок,\nсодержащие только команду, используют оболочку совместимости. Если id или ref не\nуказаны, --surface использует вызывающую поверхность cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งาน: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nแทนที่โปรเซส CLI นี้ด้วยโปรเซสพื้นผิวที่บันทึกไว้ ระเบียนใหม่จะเก็บอาร์กิวเมนต์การเปิดใช้และ cwd\nเป็นค่าที่มีโครงสร้าง ส่วนระเบียนจากบิลด์เก่าที่มีเฉพาะคำสั่งจะใช้เชลล์เพื่อความเข้ากันได้\nหากไม่ระบุ id หรือ ref ตัวเลือก --surface จะใช้พื้นผิว cmux ที่เรียกคำสั่ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nBu CLI işlemini kalıcı yüzey işlemiyle değiştirir. Yeni kayıtlar başlatma bağımsız\ndeğişkenlerini ve cwd değerini yapılandırılmış değerler olarak korur; eski\nderlemelerdeki yalnızca komut içeren kayıtlar uyumluluk kabuğu kullanır. id veya ref\nverilmezse --surface, çağıran cmux yüzeyini kullanır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\nЗамінює цей процес CLI збереженим процесом поверхні. Нові записи зберігають аргументи\nзапуску та cwd як структуровані значення; записи старих збірок, що містять лише\nкоманду, використовують оболонку сумісності. Якщо id або ref не вказано, --surface\nвикористовує поверхню cmux, що викликала команду." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\n将此 CLI 进程替换为已持久化的表面进程。新记录以结构化值保存启动参数和 cwd;\n旧版本中仅含命令的记录使用兼容 shell。未提供 id 或 ref 时,--surface 使用调用方的\ncmux 表面。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= \n cmux restore --surface [id|ref]\n\n將此 CLI 行程替換為已持久化的介面行程。新記錄會以結構化值保留啟動引數和 cwd;\n舊版本中僅含命令的記錄會使用相容 shell。未提供 id 或 ref 時,--surface 會使用呼叫端的\ncmux 介面。" + } + } + } + }, + "cli.restore.usage.positional": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Upotreba: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Brug: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用法: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការប្រើប្រាស់៖ cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Bruk: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Użycie: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Использование: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การใช้งาน: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanım: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Використання: cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux restore [--surface ] \n cmux restore --surface \n cmux restore --surface= " + } + } + } + }, + "cli.restore.usage.surface": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux restore --surface [id|ref]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用法: cmux restore --surface [id|ref]" + } + } + } + }, + "cli.sessions.command": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "sessions [list] [options]" + } + } + } + }, + "cli.simulator.error.accessibilityMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker returned no accessibility snapshot" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorワーカーからアクセシビリティスナップショットが返されませんでした" + } + } + } + }, + "cli.simulator.error.ambiguousSurface": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Multiple Simulator surfaces were found; pass surface_id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "複数のSimulatorサーフェスが見つかりました。surface_idを指定してください" + } + } + } + }, + "cli.simulator.error.callerSurfaceUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The caller surface is no longer available. Pass --surface or --window to select a Simulator." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "呼び出し元のサーフェスは利用できなくなりました。--surface または --window でSimulatorを選択してください。" + } + } + } + }, + "cli.simulator.error.cameraFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator camera operation failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorのカメラ操作に失敗しました" + } + } + } + }, + "cli.simulator.error.cameraPathRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera source %@ requires a file path" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カメラソース%@にはファイルパスが必要です" + } + } + } + }, + "cli.simulator.error.cameraStatusMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker returned no camera status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorワーカーからカメラ状態が返されませんでした" + } + } + } + }, + "cli.simulator.error.cameraUnreadable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The camera file is not a readable image or video" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カメラファイルを画像または動画として読み込めません" + } + } + } + }, + "cli.simulator.error.capabilityUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The active Simulator worker does not support this operation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "現在のSimulatorワーカーはこの操作に対応していません" + } + } + } + }, + "cli.simulator.error.deviceRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator pane has no selected device" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorペインでデバイスが選択されていません" + } + } + } + }, + "cli.simulator.error.featureDisabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator has been disabled remotely" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorはリモートで無効化されています" + } + } + } + }, + "cli.simulator.error.foregroundMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker returned no foreground-app result" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SimulatorワーカーからフォアグラウンドAppの結果が返されませんでした" + } + } + } + }, + "cli.simulator.error.inputFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator input transmission failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力の送信に失敗しました" + } + } + } + }, + "cli.simulator.error.inputTimeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator input transmission did not complete before the deadline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限までにSimulator入力の送信が完了しませんでした" + } + } + } + }, + "cli.simulator.error.inputTooLarge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator input exceeds the %lld-byte UTF-8 limit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力がUTF-8の%lldバイト制限を超えています" + } + } + } + }, + "cli.simulator.error.interfaceStatusMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker returned no interface status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorワーカーからインターフェース状態が返されませんでした" + } + } + } + }, + "cli.simulator.error.invalidArguments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid arguments for simulator %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator %@ の引数が無効です" + } + } + } + }, + "cli.simulator.error.invalidBundleIdentifier": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid Simulator application bundle identifier: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorアプリのバンドル識別子が無効です: %@" + } + } + } + }, + "cli.simulator.error.invalidCoordinate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator coordinates must be numbers from 0 through 1" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator座標は0から1までの数値で指定してください" + } + } + } + }, + "cli.simulator.error.invalidGestureJSON": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator gesture requires a JSON touch object or array" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator gesture にはJSONタッチオブジェクトまたは配列が必要です" + } + } + } + }, + "cli.simulator.error.invalidHighlight": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator highlight requires on or off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator highlight には on または off が必要です" + } + } + } + }, + "cli.simulator.error.invalidMirrorMode": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Camera mirror mode must be auto, on, or off" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "カメラのミラーモードはauto、on、offのいずれかで指定してください" + } + } + } + }, + "cli.simulator.error.invalidParameters": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid Simulator parameters" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorのパラメータが無効です" + } + } + } + }, + "cli.simulator.error.invalidPermission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator permission action or service is invalid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorの権限操作またはサービスが無効です" + } + } + } + }, + "cli.simulator.error.invalidPermissionValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid value '%@' for Simulator permission %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "値'%@'はSimulator権限%@では無効です" + } + } + } + }, + "cli.simulator.error.invalidToolsAction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator tools action must be show, hide, or toggle" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorツールの操作には show、hide、toggle のいずれかを指定してください" + } + } + } + }, + "cli.simulator.error.invalidUISetting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator interface option or value is invalid" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorのインターフェース項目または値が無効です" + } + } + } + }, + "cli.simulator.error.invalidUIValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid value '%@' for Simulator interface option %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "値'%@'はSimulatorインターフェース項目%@では無効です" + } + } + } + }, + "cli.simulator.error.invalidUTF8": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator input must be valid UTF-8" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力は有効なUTF-8である必要があります" + } + } + } + }, + "cli.simulator.error.keyboardUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator keyboard input is unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorのキーボード入力を使用できません" + } + } + } + }, + "cli.simulator.error.missingOptionValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator: %@ requires a value" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator: %@ には値が必要です" + } + } + } + }, + "cli.simulator.error.noActiveWindow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No active cmux window" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクティブなcmuxウィンドウがありません" + } + } + } + }, + "cli.simulator.error.noSelectedDevice": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator pane has no selected device" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorペインでデバイスが選択されていません" + } + } + } + }, + "cli.simulator.error.notStreaming": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The selected Simulator is not streaming" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したSimulatorはストリーミング中ではありません" + } + } + } + }, + "cli.simulator.error.operationCancelled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator operation was cancelled" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator操作はキャンセルされました" + } + } + } + }, + "cli.simulator.error.operationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator operation failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator操作に失敗しました" + } + } + } + }, + "cli.simulator.error.operationTimeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator operation did not complete before the deadline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限までにSimulator操作が完了しませんでした" + } + } + } + }, + "cli.simulator.error.operationUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This Simulator operation is unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このSimulator操作は使用できません" + } + } + } + }, + "cli.simulator.error.paneClosed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator pane closed before the operation started" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作の開始前にSimulatorペインが閉じられました" + } + } + } + }, + "cli.simulator.error.permissionStatusMissing": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker returned no permission status" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorワーカーから権限状態が返されませんでした" + } + } + } + }, + "cli.simulator.error.remoteWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator control is unavailable in remote workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リモートワークスペースではSimulatorを操作できません" + } + } + } + }, + "cli.simulator.error.simulatorNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Simulator surface was found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulatorサーフェスが見つかりません" + } + } + } + }, + "cli.simulator.error.sourceRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator input requires a positional value, --stdin, or --file" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力には位置引数、--stdin、または--fileが必要です" + } + } + } + }, + "cli.simulator.error.sourcesExclusive": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator input sources are mutually exclusive" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力元は同時に指定できません" + } + } + } + }, + "cli.simulator.error.surfaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスが見つかりません" + } + } + } + }, + "cli.simulator.error.surfaceNotSimulator": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The target surface is not a Simulator" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "対象のサーフェスはSimulatorではありません" + } + } + } + }, + "cli.simulator.error.textEmpty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text must not be empty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキストを空にすることはできません" + } + } + } + }, + "cli.simulator.error.textTooLong": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text exceeds the %lld-byte UTF-8 limit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキストがUTF-8の%lldバイト制限を超えています" + } + } + } + }, + "cli.simulator.error.tooManyOperations": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Too many Simulator operations are already running" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "実行中のSimulator操作が多すぎます" + } + } + } + }, + "cli.simulator.error.transmissionUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Simulator input transmission is unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力を送信できません" + } + } + } + }, + "cli.simulator.error.unexpectedArgument": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator input accepts one quoted positional value" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Simulator入力の位置引数は引用符で囲んだ1つの値だけ指定できます" + } + } + } + }, + "cli.simulator.error.unexpectedArgumentForCommand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator %@ does not accept input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator %@ は入力を受け付けません" + } + } + } + }, + "cli.simulator.error.unknownButton": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Simulator hardware button: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorハードウェアボタン: %@" + } + } + } + }, + "cli.simulator.error.unknownCADiagnostic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Core Animation diagnostic: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なCore Animation診断: %@" + } + } + } + }, + "cli.simulator.error.unknownCameraSource": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Simulator camera source: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorカメラソース: %@" + } + } + } + }, + "cli.simulator.error.unknownFlag": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "simulator: unknown flag '%@'" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "simulator: 不明なフラグ '%@'" + } + } + } + }, + "cli.simulator.error.unknownOrientation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Simulator orientation: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorの向き: %@" + } + } + } + }, + "cli.simulator.error.unknownPermission": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown or unsupported Simulator permission: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明または未対応のSimulator権限です: %@" + } + } + } + }, + "cli.simulator.error.unknownSubcommand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown simulator subcommand: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorサブコマンド: %@" + } + } + } + }, + "cli.simulator.error.unknownTouchEdge": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Simulator touch edge: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorタッチエッジ: %@" + } + } + } + }, + "cli.simulator.error.unknownTouchPhase": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Simulator touch phase: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorタッチフェーズ: %@" + } + } + } + }, + "cli.simulator.error.unknownUIOption": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown Simulator interface option: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なSimulatorインターフェース項目です: %@" + } + } + } + }, + "cli.simulator.error.unsupportedText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Text contains a character unsupported by Simulator input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "テキストにSimulator入力で使用できない文字が含まれています" + } + } + } + }, + "cli.simulator.error.webInspectorDetached": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Web Inspector target is attached" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web Inspector対象が接続されていません" + } + } + } + }, + "cli.simulator.error.webInspectorFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Web Inspector operation failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web Inspector操作に失敗しました" + } + } + } + }, + "cli.simulator.error.webInspectorReleaseFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Web Inspector session could not be released" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web Inspectorセッションを解放できませんでした" + } + } + } + }, + "cli.simulator.error.webInspectorTargetNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Web Inspector target was not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web Inspector対象が見つかりません" + } + } + } + }, + "cli.simulator.error.webInspectorTimeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Web Inspector operation did not complete before the deadline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限までにWeb Inspector操作が完了しませんでした" + } + } + } + }, + "cli.simulator.error.webInspectorUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Native Web Inspector is unavailable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネイティブWeb Inspectorを使用できません" + } + } + } + }, + "cli.simulator.error.workerStopped": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker stopped before completing the operation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作の完了前にSimulatorワーカーが停止しました" + } + } + } + }, + "cli.simulator.error.workerTimeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The Simulator worker did not respond before the deadline" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "期限までにSimulatorワーカーが応答しませんでした" + } + } + } + }, + "cli.simulator.error.workspaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースが見つかりません" + } + } + } + }, + "cli.simulator.output.accepted": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Completed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "完了しました" + } + } + } + }, + "cli.simulator.output.accessibilityTruncated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Accessibility results reached the 500-element limit" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アクセシビリティ結果が500要素の上限に達しました" + } + } + } + }, + "cli.simulator.output.interfaceUpdated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Set %@ to %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@を%@に設定しました" + } + } + } + }, + "cli.simulator.output.noForegroundApplication": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No foreground application" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォアグラウンドAppがありません" + } + } + } + }, + "cli.simulator.output.noInterfaceSettings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No interface settings" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インターフェース設定がありません" + } + } + } + }, + "cli.simulator.output.noPermissions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No permission values" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限の値がありません" + } + } + } + }, + "cli.simulator.output.noTargets": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No Web Inspector targets" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Web Inspector対象がありません" + } + } + } + }, + "cli.simulator.output.permissionUpdated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ %@ for %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作: %@、権限: %@、アプリ: %@" + } + } + } + }, + "cli.simulator.output.permissionsTruncated": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Permission results were truncated at 256 applications" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "権限の結果は256個のアプリで切り捨てられました" + } + } + } + }, + "cli.simulator.output.typed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Typed %lld character(s)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%lld文字を入力しました" + } + } + } + }, + "cli.simulator.permissions.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage:\n cmux simulator permissions list [bundle-id] [--surface ]\n cmux simulator permissions grant [--value ] [--surface ]\n cmux simulator permissions revoke [--surface ]\n cmux simulator permissions reset [--surface ]\n\nValues: photos limited; notifications critical; location always, inuse, or never." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法:\n cmux simulator permissions list [bundle-id] [--surface ]\n cmux simulator permissions grant [--value ] [--surface ]\n cmux simulator permissions revoke [--surface ]\n cmux simulator permissions reset [--surface ]\n\n値: photos は limited、notifications は critical、location は always、inuse、never。" + } + } + } + }, + "cli.simulator.ui.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage:\n cmux simulator ui [status] [--surface ]\n cmux simulator ui [get]