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/.github/test-determinism-allowlist.txt b/.github/test-determinism-allowlist.txt index 923163d72e9b..2bb9bb534791 100644 --- a/.github/test-determinism-allowlist.txt +++ b/.github/test-determinism-allowlist.txt @@ -2,10 +2,10 @@ # Format: relpathruleshort reason # A finding whose (path, rule) appears here is suppressed. # Remove a line once the underlying test is determinized. -Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift sleep-then-assert temporary outage debt owned by #8931; remove when determinized +Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift sleep-then-assert temporary outage debt owned by #8931; remove when determinized Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift sleep-then-assert temporary outage debt owned by #9163; remove when determinized cmuxTests/WorkspaceForkConversationContextMenuTests.swift assert-on-duration grandfathered cmuxTests/WorkspaceForkConversationContextMenuTests.swift sleep-then-assert grandfathered tests/test_ci_sparkle_build_monotonic.sh live-network-host release pretag guard intentionally probes the published appcast and soft-passes offline Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxRetryAfterPolicyTests.swift sleep-then-assert upstream retry policy timing assertions -Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift sleep-then-assert upstream coordinator readiness timing assertion +Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift sleep-then-assert upstream coordinator readiness timing assertion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea0915087c79..5dfcd22d091b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1758,6 +1758,8 @@ jobs: CmuxControlSocket CmuxFoundation CmuxGit + CmuxHive + CmuxHiveUI CmuxMobileTerminalKit CmuxMobileWorkspace CmuxNotifications diff --git a/.github/workflows/test-ios.yml b/.github/workflows/test-ios.yml index 7f0b9111e066..c964953e2247 100644 --- a/.github/workflows/test-ios.yml +++ b/.github/workflows/test-ios.yml @@ -162,7 +162,7 @@ jobs: - name: Run CmuxMobilePairedMac package tests run: | - swift test --package-path Packages/iOS/CmuxMobilePairedMac + swift test --package-path Packages/Shared/CmuxMobilePairedMac - name: Run CmuxMobileChanges package tests run: | @@ -173,13 +173,13 @@ jobs: # 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 - name: Run CmuxMobileShellModel package tests run: | # Pure shell-model queue and ordering regressions live in this package. # Keep them beside the shell package gate so input framing stays covered. - swift test --package-path Packages/iOS/CmuxMobileShellModel + swift test --package-path Packages/Shared/CmuxMobileShellModel ios-simulator: needs: detect-ios-changes diff --git a/CLI/CLIError.swift b/CLI/CLIError.swift index c9266d7bf24f..d10810c80b6f 100644 --- a/CLI/CLIError.swift +++ b/CLI/CLIError.swift @@ -1,5 +1,4 @@ -import Foundation - +/// Carries CLI failure metadata independently of executable startup. struct CLIError: Error, CustomStringConvertible { enum SocketFailureKind: Equatable { case pathMissing diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift new file mode 100644 index 000000000000..b49c54c19d30 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxPairingCode.swift @@ -0,0 +1,127 @@ +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. + // lint:allow namespace-type — this type owns code/expiry state; this is a value factory. + 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. + // lint:allow namespace-type — this type owns code/expiry state; this decodes that value. + 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 = Self.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. + // lint:allow namespace-type — normalization is part of this value's claim grammar. + public static func normalizedClaimInput(_ rawInput: String) -> String? { + let digits = String(rawInput.compactMap { character -> Character? in + guard character.unicodeScalars.count == 1, + let scalar = character.unicodeScalars.first, + (48...57).contains(scalar.value) else { + return nil + } + return character + }) + 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). + // lint:allow static-helper — formatter construction stays inside the value's + // Sendable boundary and is only used on registry label reads. + 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/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/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift new file mode 100644 index 000000000000..21434785b7c1 --- /dev/null +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/CmxPairingCodeTests.swift @@ -0,0 +1,68 @@ +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("042117") == nil) + #expect(CmxPairingCode.normalizedClaimInput("") == nil) + } +} 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/MobileDebugLogAppendCoordinator.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogAppendCoordinator.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogAppendCoordinator.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileDebugLogAppendCoordinator.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/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift diff --git a/Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift b/Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift similarity index 100% rename from Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift rename to Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.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/MobilePairedMacExactScope.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacExactScope.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacExactScope.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacExactScope.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+DeviceIDCanonicalization.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+DeviceIDCanonicalization.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+DeviceIDCanonicalization.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+DeviceIDCanonicalization.swift diff --git a/Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Queries.swift b/Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Queries.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Queries.swift rename to Packages/Shared/CmuxMobilePairedMac/Sources/CmuxMobilePairedMac/MobilePairedMacStore+Queries.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/MobilePairedMacDeviceIDCanonicalizationTests.swift b/Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacDeviceIDCanonicalizationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacDeviceIDCanonicalizationTests.swift rename to Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacDeviceIDCanonicalizationTests.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/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacTeamIsolationTests.swift b/Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacTeamIsolationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacTeamIsolationTests.swift rename to Packages/Shared/CmuxMobilePairedMac/Tests/CmuxMobilePairedMacTests/MobilePairedMacTeamIsolationTests.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/MobileArtifactLaneConnection.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileArtifactLaneConnection.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileArtifactLaneConnection.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileArtifactLaneConnection.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCommandResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCommandResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCommandResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCommandResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCreateParameters.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCreateParameters.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCreateParameters.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserCreateParameters.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserFrameAckParameters.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserFrameAckParameters.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserFrameAckParameters.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserFrameAckParameters.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListParameters.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListParameters.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListParameters.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListParameters.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserListResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserNavigateParameters.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserNavigateParameters.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserNavigateParameters.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserNavigateParameters.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserPanelParameters.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserPanelParameters.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserPanelParameters.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserPanelParameters.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserRPCRequestEncoder.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserRPCRequestEncoder.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserRPCRequestEncoder.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileBrowserRPCRequestEncoder.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCaffeineStatus.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCaffeineStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCaffeineStatus.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCaffeineStatus.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+BrowserStream.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+BrowserStream.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+BrowserStream.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+BrowserStream.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+ReleaseGate.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+ReleaseGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+ReleaseGate.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+ReleaseGate.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SimulatorStream.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SimulatorStream.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SimulatorStream.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SimulatorStream.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+SurfaceFocus.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient+Todo.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/MobileCoreRPCPipelinedRequest.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCPipelinedRequest.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCPipelinedRequest.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCPipelinedRequest.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+IndependentEvents.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+IndependentEvents.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+ReleaseGate.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+ReleaseGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+ReleaseGate.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+ReleaseGate.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCSession+RequestSettlement.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/MobileHostPhonePushStatus.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostPhonePushStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostPhonePushStatus.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileHostPhonePushStatus.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/MobileNotificationFeedChangedEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedChangedEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedChangedEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedChangedEvent.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListItem.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListItem.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListItem.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListItem.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListStringLimits.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedMutationResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedMutationResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedMutationResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedMutationResponse.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/MobileRPCAbandonedCandidateClose.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedCandidateClose.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedCandidateClose.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedCandidateClose.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedConnectCleaner.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedConnectCleaner.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedConnectCleaner.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCAbandonedConnectCleaner.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCClientLifecycleGate.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCClientLifecycleGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCClientLifecycleGate.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCClientLifecycleGate.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAdmission.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAdmission.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAdmission.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAdmission.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.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/MobileRPCConnectCancellationClose.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectCancellationClose.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectCancellationClose.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectCancellationClose.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectEndpointIdentity.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectEndpointIdentity.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectEndpointIdentity.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectEndpointIdentity.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/MobileRPCRejectedTransportDisposal.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCRejectedTransportDisposal.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCRejectedTransportDisposal.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCRejectedTransportDisposal.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCTransportConnectEvent.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCTransportConnectEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCTransportConnectEvent.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCTransportConnectEvent.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/MobileSimulatorRPCDTOs.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorRPCDTOs.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorRPCDTOs.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorRPCDTOs.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorStreamLaneConnection.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorStreamLaneConnection.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorStreamLaneConnection.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSimulatorStreamLaneConnection.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/MobileTaskDirectoryListEntries.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntries.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntries.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntries.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListEntry.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListRequest.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectoryListResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTaskDirectorySearchResponse.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/MobileTerminalLaneConnection.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneConnection.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneConnection.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneConnection.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneOutputFrame.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneOutputFrame.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneOutputFrame.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileTerminalLaneOutputFrame.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/MobileWorkspaceChangeStatus.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangeStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangeStatus.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangeStatus.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangedFilesResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangedFilesResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangedFilesResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangedFilesResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummariesResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummariesResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummariesResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummariesResponse.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummaryRequest.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummaryRequest.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummaryRequest.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceChangesSummaryRequest.swift diff --git a/Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceFileDiffResponse.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceFileDiffResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceFileDiffResponse.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileWorkspaceFileDiffResponse.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/RPCTaskTimeoutCancellation.swift b/Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift rename to Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.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/CancellationSensitiveCloseTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationSensitiveCloseTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationSensitiveCloseTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationSensitiveCloseTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationThrowingConnectHangingCloseTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationThrowingConnectHangingCloseTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationThrowingConnectHangingCloseTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationThrowingConnectHangingCloseTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachEndpointLogDescriptionTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachEndpointLogDescriptionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachEndpointLogDescriptionTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CmxAttachEndpointLogDescriptionTests.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/ControllableResponseTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ControllableResponseTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ControllableResponseTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/ControllableResponseTransport.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/FirstConnectClosedErrorThenSucceedsTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectClosedErrorThenSucceedsTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectClosedErrorThenSucceedsTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/FirstConnectClosedErrorThenSucceedsTransport.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/InterleavingSessionPurposeTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/InterleavingSessionPurposeTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/InterleavingSessionPurposeTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/InterleavingSessionPurposeTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileBrowserRPCDTOTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileBrowserRPCDTOTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileBrowserRPCDTOTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileBrowserRPCDTOTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCaffeineStatusTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCaffeineStatusTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCaffeineStatusTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCaffeineStatusTests.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/MobileCoreRPCIndependentEventTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCIndependentEventTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCIndependentEventTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCIndependentEventTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCNotificationFeedAuthTests.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/MobileCoreRPCSessionPipelinedTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCSessionPipelinedTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCSessionPipelinedTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCSessionPipelinedTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStaleReaderRecoveryTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStaleReaderRecoveryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStaleReaderRecoveryTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStaleReaderRecoveryTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStalledWriteRecoveryTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStalledWriteRecoveryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStalledWriteRecoveryTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCStalledWriteRecoveryTests.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/MobileCoreRPCTransportDrainTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCWorkspaceMoveTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCWorkspaceMoveTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCWorkspaceMoveTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCWorkspaceMoveTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileNotificationFeedDTOTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCClientLifecycleGateTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCClientLifecycleGateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCClientLifecycleGateTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCClientLifecycleGateTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCTransportConnectEventTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCTransportConnectEventTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCTransportConnectEventTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileRPCTransportConnectEventTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSimulatorRPCDTOTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSimulatorRPCDTOTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSimulatorRPCDTOTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSimulatorRPCDTOTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileSurfaceInventoryTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectoryListDTOTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileTaskDirectorySearchResponseTests.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/MobileWorkspaceChangesDTODecodeTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesDTODecodeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesDTODecodeTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesDTODecodeTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesSummaryRequestTests.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesSummaryRequestTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesSummaryRequestTests.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileWorkspaceChangesSummaryRequestTests.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PhysicalCleanupGate.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PhysicalCleanupGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PhysicalCleanupGate.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PhysicalCleanupGate.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PurposeBarrierConnectTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PurposeBarrierConnectTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PurposeBarrierConnectTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/PurposeBarrierConnectTransport.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/SequencedTransportFactory.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SequencedTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SequencedTransportFactory.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SequencedTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SessionPurposeRecordingTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SessionPurposeRecordingTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SessionPurposeRecordingTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/SessionPurposeRecordingTransport.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/StalledWriteRecoveryTransportFactory.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteRecoveryTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteRecoveryTransportFactory.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteRecoveryTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteTransport.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteTransport.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/StalledWriteTransport.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownCompletion.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownCompletion.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownCompletion.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownCompletion.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownRegistrationGate.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownRegistrationGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownRegistrationGate.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TeardownRegistrationGate.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainCompletion.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainCompletion.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainCompletion.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainCompletion.swift diff --git a/Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainProbe.swift b/Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainProbe.swift rename to Packages/Shared/CmuxMobileRPC/Tests/CmuxMobileRPCTests/TransportDrainProbe.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 92% rename from Packages/iOS/CmuxMobileShell/Package.swift rename to Packages/Shared/CmuxMobileShell/Package.swift index c6b11e364ea8..8961653fdbb8 100644 --- a/Packages/iOS/CmuxMobileShell/Package.swift +++ b/Packages/Shared/CmuxMobileShell/Package.swift @@ -19,11 +19,11 @@ let package = Package( ), ], dependencies: [ - .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../../Shared/CmuxAgentChat"), - .package(path: "../CmuxMobileChanges"), + .package(path: "../CMUXMobileCore"), + .package(path: "../CmuxAgentChat"), + .package(path: "../../iOS/CmuxMobileChanges"), .package(path: "../CmuxMobileDiagnostics"), - .package(path: "../CmuxMobileBrowserStream"), + .package(path: "../../iOS/CmuxMobileBrowserStream"), .package(path: "../CmuxMobilePairedMac"), .package(path: "../CmuxMobileRPC"), .package(path: "../CmuxMobileShellModel"), 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/CreatedTerminalSelection.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/CreatedTerminalSelection.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/Debug/ThemeParityPreviewDelivery.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/DemoContentPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DemoContentPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DemoContentPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DemoContentPairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceIdentityStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceIdentityStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceIdentityStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceIdentityStore.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 98% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift index 06c6120abeb0..32ee8cba431e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift +++ b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceRegistryService.swift @@ -643,16 +643,24 @@ 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 let platform: String? let displayName: String? let lastSeenAt: String? + let isOwnedByCurrentUser: Bool? let instances: [Instance]? } struct ListResponse: Decodable { @@ -669,7 +677,8 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { return RegistryAppInstance( tag: tag?.isEmpty == false ? tag! : "default", routes: (instance.routes ?? []).compactMap(\.value), - lastSeenAt: Self.parseTimestamp(instance.lastSeenAt) + lastSeenAt: Self.parseTimestamp(instance.lastSeenAt), + labels: (instance.labels ?? [:]).compactMapValues(\.value) ) } return RegistryDevice( @@ -678,7 +687,8 @@ public actor DeviceRegistryService: DeviceRegistryRefreshing { ? device.platform! : "mac", displayName: device.displayName, lastSeenAt: Self.parseTimestamp(device.lastSeenAt), - instances: instances + instances: instances, + isOwnedByCurrentUser: device.isOwnedByCurrentUser ?? false ) } } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/ForegroundConnectionAttemptReservation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/ForegroundConnectionAttemptReservation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/ForegroundConnectionAttemptReservation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/ForegroundConnectionAttemptReservation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierIdentityProvider.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierIdentityProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierIdentityProvider.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierIdentityProvider.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierPairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierPairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierPairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/HideComputersVerifierPairedMacStore.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/InMemoryPairedMacHiddenStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacHiddenStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacHiddenStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/InMemoryPairedMacHiddenStore.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/MacBuildAdmissionVerdict.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildAdmissionVerdict.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildAdmissionVerdict.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacBuildAdmissionVerdict.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/MacPairingKey.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacPairingKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MacPairingKey.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MacPairingKey.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactChunkFetchLoop.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactLaneFetchLoop.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactLaneFetchLoop.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactLaneFetchLoop.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileArtifactLaneFetchLoop.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutomaticReconnectBackoffOwner.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutomaticReconnectBackoffOwner.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutomaticReconnectBackoffOwner.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutomaticReconnectBackoffOwner.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileBrowserStreamLifecycleCoordinator.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileBrowserStreamLifecycleCoordinator.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileBrowserStreamLifecycleCoordinator.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileBrowserStreamLifecycleCoordinator.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatArtifactFailureClassifier.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+PanelArtifacts.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.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/MobileConnectionRecoveryOwner.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileConnectionRecoveryOwner.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileControlPoolRetryState.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileControlPoolRetryState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileControlPoolRetryState.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileControlPoolRetryState.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDemoContentSession.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDemoContentSession.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDemoContentSession.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDemoContentSession.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiscoveredIrohMac.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiscoveredIrohMac.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiscoveredIrohMac.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileDiscoveredIrohMac.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/MobileHiddenComputer.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHiddenComputer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHiddenComputer.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHiddenComputer.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationCheckpoint.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationCheckpoint.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationCheckpoint.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationCheckpoint.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationResult.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationResult.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationResult.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationResult.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationWorkspace.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationWorkspace.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationWorkspace.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerificationWorkspace.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerifier.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerifier.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerifier.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileHideComputersVerifier.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacDiscovering.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacDiscovering.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacDiscovering.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacDiscovering.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacForgetting.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacForgetting.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacForgetting.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileIrohMacForgetting.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.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/MobileMacBuildCompatibilityPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildCompatibilityPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildVersionStamp.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildVersionStamp.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildVersionStamp.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacBuildVersionStamp.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatDebugOverride.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Channel.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Channel.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Channel.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Channel.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+NightlyRequirement.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+NightlyRequirement.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+NightlyRequirement.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+NightlyRequirement.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Tier.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Tier.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Tier.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Tier.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Violation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Violation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Violation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy+Violation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteEntry.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteEntry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteEntry.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteEntry.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteList.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteList.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteList.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteList.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteNightly.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteNightly.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteNightly.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatRemoteNightly.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatiblePairedMacStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+ControlSubscriptions.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+ControlSubscriptions.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+ControlSubscriptions.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+ControlSubscriptions.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+Entry.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+Entry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+Entry.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+Entry.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+FocusedConnections.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+FocusedConnections.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+FocusedConnections.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry+FocusedConnections.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRole.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRole.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRole.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRole.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionSnapshot.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/MobileMacInstanceTagExpectation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagExpectation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagExpectation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagExpectation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagResolution.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagResolution.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagResolution.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacInstanceTagResolution.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacTagAllowlist.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/MobilePhonePushTestStage.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePhonePushTestStage.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePhonePushTestStage.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePhonePushTestStage.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePresencePushRecoveryThrottle.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePresencePushRecoveryThrottle.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePresencePushRecoveryThrottle.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobilePresencePushRecoveryThrottle.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileSecondaryStatusAuthority.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileSecondaryStatusAuthority.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileSecondaryStatusAuthority.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileSecondaryStatusAuthority.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+AppDiagnostics.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BrowserStream.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BrowserStream.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BrowserStream.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BrowserStream.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+BuildCompatibility.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Caffeine.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Caffeine.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Caffeine.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Caffeine.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+CompatibleMacTags.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+CompatibleMacTags.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+CompatibleMacTags.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+CompatibleMacTags.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ComposerFileAttachments.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ComposerFileAttachments.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ComposerFileAttachments.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ComposerFileAttachments.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionDiagnostics.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionDiagnostics.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionDiagnostics.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionDiagnostics.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionMethod.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionMethod.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionMethod.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionMethod.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.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+DemoContent.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DemoContent.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DemoContent.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+DemoContent.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+ExplicitTerminalInput.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ExplicitTerminalInput.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+HiddenMacs.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+HiddenMacs.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+IrohPeerFocus.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohPeerFocus.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohPeerFocus.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohPeerFocus.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohReleaseGate.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohReleaseGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohReleaseGate.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+IrohReleaseGate.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+LatencyProbe.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+LatencyProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+LatencyProbe.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+LatencyProbe.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+NotificationFeed.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.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+PairingConnectionStatus.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingConnectionStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingConnectionStatus.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairingConnectionStatus.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+RouteRemoval.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteRemoval.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteRemoval.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteRemoval.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+RouteSelection.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+SimulatorDiagnostics.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorDiagnostics.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorDiagnostics.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorDiagnostics.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStream.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStream.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStream.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStream.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStreamV2.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStreamV2.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStreamV2.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SimulatorStreamV2.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+SurfaceFocus.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskAttachments.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskComposer.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskDirectoryList.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalLane.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalLane.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalLane.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalLane.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+TerminalTheme.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalTheme.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalTheme.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalTheme.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+Todo.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Todo.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+WorkspaceChanges.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChanges.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesContent.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+WorkspaceChangesPruning.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+ZeroTouchIroh.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ZeroTouchIroh.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/MobileShellTerminalReplayOffMainDecoding.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellTerminalReplayOffMainDecoding.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellTerminalReplayOffMainDecoding.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellTerminalReplayOffMainDecoding.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/MobileSimulatorStreamStalenessMonitor.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStalenessMonitor.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStalenessMonitor.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStalenessMonitor.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileSimulatorStreamStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskDirectoryListFailure.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheEntry.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCacheKey.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskModelCatalogClient.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTaskTemplateStoring.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalEventSubscriptionReadiness.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalEventSubscriptionReadiness.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalEventSubscriptionReadiness.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalEventSubscriptionReadiness.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalInputRPCPipeline.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalInputRPCPipeline.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalInputRPCPipeline.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalInputRPCPipeline.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalLaneCoordinator.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/MobileTerminalSubscriptionValidation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalSubscriptionValidation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalSubscriptionValidation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalSubscriptionValidation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalThemeState.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalThemeState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalThemeState.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalThemeState.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/MobileTerminalViewportSequenceKey.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportSequenceKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportSequenceKey.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileTerminalViewportSequenceKey.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangeStatus+FileChangeKind.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangeStatus+FileChangeKind.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangeStatus+FileChangeKind.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangeStatus+FileChangeKind.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesChip.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesChip.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesChip.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesChip.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHint.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHint.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHint.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHint.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHintDismissalStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHintDismissalStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHintDismissalStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceChangesHintDismissalStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileWorkspaceCreateSpec.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/NotificationFeedClientTarget.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedClientTarget.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedClientTarget.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedClientTarget.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedFetchOutcome.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedFetchOutcome.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedFetchOutcome.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedFetchOutcome.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedMacSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedMacSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedMacSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedMacSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceLookupKey.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceLookupKey.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceLookupKey.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceLookupKey.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTarget.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTarget.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTarget.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTarget.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTargetIndex.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTargetIndex.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTargetIndex.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/NotificationFeedWorkspaceTargetIndex.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacAliasUnionFind.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacAliasUnionFind.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacAliasUnionFind.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacAliasUnionFind.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/PairedMacBackupClientScopeSelection.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClientScopeSelection.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClientScopeSelection.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupClientScopeSelection.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/PairedMacBackupMigrationPlan.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationPlan.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationPlan.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationPlan.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationScope.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationScope.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationScope.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupMigrationScope.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/PairedMacBackupRouteDisclosure.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRouteDisclosure.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRouteDisclosure.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupRouteDisclosure.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/PairedMacBackupTeamStoring.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupTeamStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupTeamStoring.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacBackupTeamStoring.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacFetchedSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacFetchedSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacFetchedSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacFetchedSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacHiddenStoring.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacHiddenStoring.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacHiddenStoring.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PairedMacHiddenStoring.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/PendingTerminalSubscriptionHandoff.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PendingTerminalSubscriptionHandoff.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PendingTerminalSubscriptionHandoff.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/PendingTerminalSubscriptionHandoff.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/SameDeviceEvidence.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SameDeviceEvidence.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SameDeviceEvidence.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SameDeviceEvidence.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientAttempt.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientAttempt.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientAttempt.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryClientAttempt.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/SecondaryControlAttemptPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryEventSubscriptionActivation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryEventSubscriptionActivation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryEventSubscriptionActivation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryEventSubscriptionActivation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryHostStatusAttempt.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryHostStatusAttempt.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryHostStatusAttempt.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryHostStatusAttempt.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentFlight.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentFlight.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentFlight.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentFlight.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentOutcome.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentOutcome.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentOutcome.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacEstablishmentOutcome.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacPostDrainAction.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacPostDrainAction.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacPostDrainAction.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacPostDrainAction.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/SecondaryMacTransportDrainOperation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainOperation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainOperation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainOperation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainWaiter.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainWaiter.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainWaiter.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryMacTransportDrainWaiter.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryOwnedEventSubscriptionActivation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryOwnedEventSubscriptionActivation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryOwnedEventSubscriptionActivation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryOwnedEventSubscriptionActivation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryStoredAuthorityValidation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryStoredAuthorityValidation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryStoredAuthorityValidation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryStoredAuthorityValidation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceFetchAttempt.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceFetchAttempt.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceFetchAttempt.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceFetchAttempt.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceSnapshot.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceSnapshot.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryWorkspaceSnapshot.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/TerminalConfigThemeMismatchResetPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalConfigThemeMismatchResetPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalConfigThemeMismatchResetPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalConfigThemeMismatchResetPolicy.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/TerminalOutputTransportSelection.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputTransportSelection.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputTransportSelection.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/TerminalOutputTransportSelection.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/UserDefaultsPairedMacBackupTeamStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacBackupTeamStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacBackupTeamStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacBackupTeamStore.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacHiddenStore.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacHiddenStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacHiddenStore.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/UserDefaultsPairedMacHiddenStore.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/WarmControlPoolCapacity.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceAbsenceAuthority.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentFingerprintPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentRequest.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentRequest.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentRequest.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentRequest.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentResponse.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentResponse.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentResponse.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesContentResponse.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesExpansionDownloadPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesExpansionDownloadPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesExpansionDownloadPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesExpansionDownloadPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesFileRevision.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesFileRevision.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesFileRevision.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesFileRevision.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPlan.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPlan.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPlan.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPlan.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryFetchPolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshSchedulePolicy.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshSchedulePolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshSchedulePolicy.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshSchedulePolicy.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshScope.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshScope.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshScope.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryRefreshScope.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryWorkspaceSet.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryWorkspaceSet.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryWorkspaceSet.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceChangesSummaryWorkspaceSet.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WorkspaceCreatePinnedContext.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/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateArtifactPreparation.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeFailure.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateProbeResult.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRPCMethodInventory.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRPCMethodInventory.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRPCMethodInventory.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRPCMethodInventory.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRenderGridProbe.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRenderGridProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRenderGridProbe.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateRenderGridProbe.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateScenario.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalProbe.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalProbe.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateTerminalProbe.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift b/Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.swift rename to Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileShellComposite+IrohReleaseGate.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/ComposerFileAttachmentSubmitTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerFileAttachmentSubmitTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerFileAttachmentSubmitTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerFileAttachmentSubmitTests.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/ComposerSubmitRoutingStoreBuilders.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingStoreBuilders.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/ConnectionTeardownWorkspaceRetentionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectionTeardownWorkspaceRetentionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectionTeardownWorkspaceRetentionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ConnectionTeardownWorkspaceRetentionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClock.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClock.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClock.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClock.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockInstant.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockInstant.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockInstant.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockInstant.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockSleeper.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockSleeper.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockSleeper.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolManualClockSleeper.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolRouteSyncGate.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolRouteSyncGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolRouteSyncGate.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ControlPoolRouteSyncGate.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/CreatedTerminalSelectionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CreatedTerminalSelectionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/CreatedTerminalSelectionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/CreatedTerminalSelectionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedFirstHidePairedMacHiddenStore.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedFirstHidePairedMacHiddenStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedFirstHidePairedMacHiddenStore.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DelayedFirstHidePairedMacHiddenStore.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/DeviceOnlyMacSwitchTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceOnlyMacSwitchTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceOnlyMacSwitchTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceOnlyMacSwitchTests.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/DeviceRegistryRedirectTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRedirectTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRedirectTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRedirectTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRequestDedupTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRequestDedupTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRequestDedupTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/DeviceRegistryRequestDedupTests.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/FailingPoolTransportFactory.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/FailingPoolTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/FailingPoolTransportFactory.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/FailingPoolTransportFactory.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/IdlePresence.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IdlePresence.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IdlePresence.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IdlePresence.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryDeviceIdentityStore.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryDeviceIdentityStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryDeviceIdentityStore.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/InMemoryDeviceIdentityStore.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/InputAckRetryClock.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/InputAckRetryClock.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/InputAckRetryClock.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/InputAckRetryClock.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohConnectionRecoveryOwnerTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteDedupTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteDedupTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteDedupTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteDedupTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohReconnectRouteSelectionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/IrohZeroTouchDiscoveryTests.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/LivenessTransportCloseGate.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessTransportCloseGate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessTransportCloseGate.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/LivenessTransportCloseGate.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/MacPairingKeyTestShorthand.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MacPairingKeyTestShorthand.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MacPairingKeyTestShorthand.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MacPairingKeyTestShorthand.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MigrationUserProbe.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MigrationUserProbe.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MigrationUserProbe.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MigrationUserProbe.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactLaneFetchLoopTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactLaneFetchLoopTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactLaneFetchLoopTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactLaneFetchLoopTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutomaticReconnectBackoffOwnerTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutomaticReconnectBackoffOwnerTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutomaticReconnectBackoffOwnerTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutomaticReconnectBackoffOwnerTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileChatArtifactFailureClassifierTests.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/MobileForegroundTransportDiagnosticsTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileForegroundTransportDiagnosticsTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateArtifactPreparationTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateResponseValidatorTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTargetTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTargetTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTargetTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTargetTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTerminalProbeTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTerminalProbeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTerminalProbeTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileIrohReleaseGateTerminalProbeTests.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/MobileMacBuildCompatibilityPolicyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacBuildCompatibilityPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacCompatPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.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/MobilePairedMacPersistenceFailureTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairedMacPersistenceFailureTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairedMacPersistenceFailureTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePairedMacPersistenceFailureTests.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/MobilePresencePushRecoveryThrottleTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePresencePushRecoveryThrottleTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePresencePushRecoveryThrottleTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobilePresencePushRecoveryThrottleTests.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/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/MobileShellCompositeForgetRevokeAccountPinningTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetRevokeAccountPinningTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetRevokeAccountPinningTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetRevokeAccountPinningTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetScopeFlipTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetScopeFlipTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetScopeFlipTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetScopeFlipTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetTeamlessScopeTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetTeamlessScopeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetTeamlessScopeTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetTeamlessScopeTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetWildcardBreadthTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetWildcardBreadthTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetWildcardBreadthTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeForgetWildcardBreadthTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHiddenMacRefreshTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHiddenMacRefreshTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHiddenMacRefreshTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHiddenMacRefreshTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHideMacTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHideMacTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHideMacTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeHideMacTests.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/MobileShellCompositePairingScopeTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairingScopeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairingScopeTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositePairingScopeTests.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/MobileShellCompositeRouteRemovalTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeRouteRemovalTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeRouteRemovalTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeRouteRemovalTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeSimulatorStreamTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeSimulatorStreamTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeSimulatorStreamTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellCompositeSimulatorStreamTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDemoContentTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDemoContentTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDemoContentTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellDemoContentTests.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/MobileShellMacAvailabilityFailureClassifierTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellMacAvailabilityFailureClassifierTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellMacAvailabilityFailureClassifierTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellMacAvailabilityFailureClassifierTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellNotificationFeedStateTests.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/MobileShellStateSyncTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalLaneConfigurationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalLaneConfigurationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalLaneConfigurationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalLaneConfigurationTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayOffMainDecodingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayOffMainDecodingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayOffMainDecodingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellTerminalReplayOffMainDecodingTests.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/MobileShellWorkspaceMetadataActionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceMetadataActionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceMetadataActionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceMetadataActionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSignOutPlaceholderWorkspaceTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSignOutPlaceholderWorkspaceTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSignOutPlaceholderWorkspaceTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSignOutPlaceholderWorkspaceTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStalenessMonitorTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStalenessMonitorTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStalenessMonitorTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStalenessMonitorTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileSimulatorStreamStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerCancellationTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskComposerSubmitTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectoryListTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskDirectorySearchTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskTemplateStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalLaneCoordinatorTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalViewportSequenceKeyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalViewportSequenceKeyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalViewportSequenceKeyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTerminalViewportSequenceKeyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileWorkspaceChangesHintTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileWorkspaceChangesHintTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileWorkspaceChangesHintTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileWorkspaceChangesHintTests.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/NotificationResponseEntry.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/NotificationResponseEntry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/NotificationResponseEntry.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/NotificationResponseEntry.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupEchoRoutingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupEchoRoutingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupEchoRoutingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupEchoRoutingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupIrohPrivacyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupIrohPrivacyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupIrohPrivacyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupIrohPrivacyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationURLProtocol.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationURLProtocol.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationURLProtocol.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupMigrationURLProtocol.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTeamRoutingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTeamRoutingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTeamRoutingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTeamRoutingTests.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/PairedMacMigrationClock.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacMigrationClock.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacMigrationClock.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacMigrationClock.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacPendingDeleteReplayTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacPendingDeleteReplayTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacPendingDeleteReplayTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacPendingDeleteReplayTests.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/PoolTransportAttemptCounter.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PoolTransportAttemptCounter.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PoolTransportAttemptCounter.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PoolTransportAttemptCounter.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceDiscoveryInvalidationTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceDiscoveryInvalidationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceDiscoveryInvalidationTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PresenceDiscoveryInvalidationTests.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/PromotionFenceCompletion.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFenceCompletion.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFenceCompletion.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFenceCompletion.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFocusRepairFailure.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFocusRepairFailure.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFocusRepairFailure.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionFocusRepairFailure.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionWorkspaceRace.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionWorkspaceRace.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionWorkspaceRace.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/PromotionWorkspaceRace.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests+Support.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests+Support.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests+Support.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectRouteSelectionTests+Support.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/SecondaryAuthorityIdlePresence.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SecondaryAuthorityIdlePresence.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SecondaryAuthorityIdlePresence.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SecondaryAuthorityIdlePresence.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/SequencedDeviceRegistry.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/TailscalePairingRegressionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.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/TerminalAuthoritativeResyncTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalAuthoritativeResyncTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalAuthoritativeResyncTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalAuthoritativeResyncTests.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/TerminalConfigThemeMismatchResetPolicyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalConfigThemeMismatchResetPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalConfigThemeMismatchResetPolicyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalConfigThemeMismatchResetPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalLaneReplayBarrierTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalLaneReplayBarrierTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalLaneReplayBarrierTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalLaneReplayBarrierTests.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/TerminalOutputTransportSelectionTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputTransportSelectionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputTransportSelectionTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputTransportSelectionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTestSupport.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTestSupport.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTestSupport.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTestSupport.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReconnectSnapshotTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReconnectSnapshotTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReconnectSnapshotTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalReconnectSnapshotTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRenderGridRevisionChainGateTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRenderGridRevisionChainGateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRenderGridRevisionChainGateTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRenderGridRevisionChainGateTests.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/TerminalThemeRenderGridTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeRenderGridTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeRenderGridTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalThemeRenderGridTests.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/TestBackupList.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TestBackupList.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TestBackupList.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/TestBackupList.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/CmuxMobileShell/Tests/CmuxMobileShellTests/UserDefaultsPairedMacBackupTeamStoreTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/UserDefaultsPairedMacBackupTeamStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/UserDefaultsPairedMacBackupTeamStoreTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/UserDefaultsPairedMacBackupTeamStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceAbsenceAuthorityTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentFingerprintPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentRequestTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentRequestTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentRequestTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentRequestTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentResponseTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentResponseTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentResponseTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesContentResponseTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesExpansionDownloadPolicyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesExpansionDownloadPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesExpansionDownloadPolicyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesExpansionDownloadPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesFetchErrorTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesFetchErrorTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesFetchErrorTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesFetchErrorTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryFetchPolicyTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryFetchPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryFetchPolicyTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryFetchPolicyTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryRefreshScopeTests.swift b/Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryRefreshScopeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryRefreshScopeTests.swift rename to Packages/Shared/CmuxMobileShell/Tests/CmuxMobileShellTests/WorkspaceChangesSummaryRefreshScopeTests.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/MacSurfaceRenderer.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceRenderer.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MacSurfaceTextDecoder.swift 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/MobileAutoConnectMigrationResolution.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationResolution.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationResolution.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationResolution.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationStore.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationStore.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoConnectMigrationStore.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/MobileConnectionMethodStore.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileConnectionMethodStore.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/MobileDemoCommandCatalog.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoCommandCatalog.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoContentCatalog.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoContentCatalog.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoContentCatalog.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoContentCatalog.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileDemoTerminalEngine.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/MobileImageAttachmentPreparer.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileImageAttachmentPreparer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileImageAttachmentPreparer.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileImageAttachmentPreparer.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/MobileMacListAuthState.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileMacListAuthState.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidate.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregationCandidateHeap.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedFilter.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedFilter.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedFilter.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedFilter.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItem.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItem.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItem.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItem.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItemID.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItemID.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItemID.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedItemID.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedSourceSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedStatus.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedStatus.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedStatus.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingProgress.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingProgress.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingProgress.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileOnboardingProgress.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/MobilePreparedImageAttachment.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePreparedImageAttachment.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePreparedImageAttachment.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobilePreparedImageAttachment.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 76% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift index ad3a3b2f1b9e..413a46668db6 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift +++ b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift @@ -80,19 +80,70 @@ 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) + || 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 { + 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/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileSurfacePreview.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAgentProvider.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAttachmentChunkPlan.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAttachmentChunkPlan.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAttachmentChunkPlan.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskAttachmentChunkPlan.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskCommandComposer.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraft.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraftAttachment.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraftAttachment.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraftAttachment.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerDraftAttachment.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerSavedDraft.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerSavedDraft.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerSavedDraft.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposerSavedDraft.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskComposition.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskDirectorySuggestion.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelAvailability.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListResult.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskModelListSource.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionAttachment.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionIdentity.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskSubmissionSnapshot.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTaskTemplate.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/MobileTerminalSendStatus.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalSendStatus.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalSendStatus.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTerminalSendStatus.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/MobileTodoMutation.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileTodoMutation.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWhatsNewChannelPolicy.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWhatsNewChannelPolicy.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWhatsNewChannelPolicy.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWhatsNewChannelPolicy.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/MobileWorkspaceGroupMoveMenu.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupMoveMenu.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupMoveMenu.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceGroupMoveMenu.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/MobileWorkspaceLastTabStore.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceLastTabStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceLastTabStore.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceLastTabStore.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/MobileWorkspaceRecencyOrder.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceRecencyOrder.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceRecencyOrder.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceRecencyOrder.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortMode.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortMode.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortMode.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortMode.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortStore.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortStore.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortStore.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceSortStore.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceUnreadState.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceUnreadState.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceUnreadState.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileWorkspaceUnreadState.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift similarity index 75% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/RegistryDevice.swift index 2d0fc78e9240..55fe1c48dc0b 100644 --- a/Packages/iOS/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 @@ -54,6 +73,10 @@ public struct RegistryDevice: Equatable, Sendable, Identifiable { public var displayName: String? /// When the registry last saw any registration/refresh for this device. public var lastSeenAt: Date + /// Whether the authenticated registry response identifies this Mac as + /// owned by the current Stack user. Decoders fail closed when an older + /// server omits the field; local fixture initializers default to `true`. + public var isOwnedByCurrentUser: Bool /// The device's running cmux app instances (tags), newest-first. public var instances: [RegistryAppInstance] @@ -64,13 +87,15 @@ public struct RegistryDevice: Equatable, Sendable, Identifiable { platform: String, displayName: String?, lastSeenAt: Date, - instances: [RegistryAppInstance] + instances: [RegistryAppInstance], + isOwnedByCurrentUser: Bool = true ) { self.deviceId = deviceId self.platform = platform self.displayName = displayName self.lastSeenAt = lastSeenAt self.instances = instances + self.isOwnedByCurrentUser = isOwnedByCurrentUser } /// A human label for the device: its display name, else the short device id. diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TaskComposerAttachment.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalComposerAttachmentInsertion.swift b/Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalComposerAttachmentInsertion.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalComposerAttachmentInsertion.swift rename to Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/TerminalComposerAttachmentInsertion.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceRendererTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MacSurfaceTextDecoderTests.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/MobileAutoConnectMigrationStoreTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoConnectMigrationStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoConnectMigrationStoreTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoConnectMigrationStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileBuildTypeTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileBuildTypeTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileBuildTypeTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileBuildTypeTests.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/MobileConnectionMethodStoreTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileConnectionMethodStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoContentCatalogTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoContentCatalogTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoContentCatalogTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoContentCatalogTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileDemoTerminalEngineTests.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/MobileImageAttachmentPreparerTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileImageAttachmentPreparerTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileImageAttachmentPreparerTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileImageAttachmentPreparerTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileMacListAuthStateTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileNotificationFeedAggregationTests.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 78% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift index 834e60902beb..74a5730a6392 100644 --- a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift +++ b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileShellRouteAuthPolicyTests.swift @@ -146,4 +146,42 @@ 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 + )) + // 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( + loopback, trust: .loopbackAndTailscaleTunnel + )) + } } diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskAgentProviderTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskCommandComposerVerbatimTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerDraftTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerSavedDraftTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerSavedDraftTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerSavedDraftTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskComposerSavedDraftTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskDirectorySuggestionTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskModelAvailabilityTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskModelAvailabilityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskModelAvailabilityTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskModelAvailabilityTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskSubmissionSnapshotTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileTaskTemplateTests.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/MobileWhatsNewChannelPolicyTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWhatsNewChannelPolicyTests.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/MobileWorkspaceGroupMoveMenuTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupMoveMenuTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupMoveMenuTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceGroupMoveMenuTests.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/MobileWorkspaceLastTabStoreTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceLastTabStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceLastTabStoreTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceLastTabStoreTests.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/MobileWorkspacePreviewDefaultSurfaceTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspacePreviewDefaultSurfaceTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspacePreviewDefaultSurfaceTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspacePreviewDefaultSurfaceTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceRecencyOrderTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceRecencyOrderTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceRecencyOrderTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceRecencyOrderTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceSortStoreTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceSortStoreTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceSortStoreTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceSortStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceUnreadStateTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceUnreadStateTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceUnreadStateTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileWorkspaceUnreadStateTests.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/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalComposerAttachmentInsertionTests.swift b/Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalComposerAttachmentInsertionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalComposerAttachmentInsertionTests.swift rename to Packages/Shared/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/TerminalComposerAttachmentInsertionTests.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/ComposerDictationDiagnosticEvent.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationDiagnosticEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationDiagnosticEvent.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationDiagnosticEvent.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+AutoConnectMigration.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+AutoConnectMigration.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+AutoConnectMigration.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+AutoConnectMigration.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+NotificationFeedPreview.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+OnboardingPreview.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+OnboardingPreview.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+OnboardingPreview.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/Debug/UITestConfig+OnboardingPreview.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/MobileComposerLayout.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerLayout.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerLayout.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerLayout.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardDismissal.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardDismissal.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardDismissal.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardDismissal.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.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/MobileKeyboardVisibilityObserver.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibilityObserver.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibilityObserver.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibilityObserver.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/UserDefaults+ForceRebuildKeyboardDock.swift b/Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UserDefaults+ForceRebuildKeyboardDock.swift similarity index 100% rename from Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UserDefaults+ForceRebuildKeyboardDock.swift rename to Packages/Shared/CmuxMobileSupport/Sources/CmuxMobileSupport/UserDefaults+ForceRebuildKeyboardDock.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/TerminalCursorRenderWakeState.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorRenderWakeState.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorRenderWakeState.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalCursorRenderWakeState.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/TerminalInputSessionReducer.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputSessionReducer.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputSessionReducer.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalInputSessionReducer.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/TerminalKeyboardDockEndpoints.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardDockEndpoints.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardDockEndpoints.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardDockEndpoints.swift diff --git a/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardDockPathSelection.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardDockPathSelection.swift new file mode 100644 index 000000000000..6c999b3b1c3f --- /dev/null +++ b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardDockPathSelection.swift @@ -0,0 +1,49 @@ +/// Selects which keyboard dock implementation a terminal host mounts with. +/// +/// The legacy notification+transform path is the shipping default on every +/// OS. The rebuilt single-constraint path is reachable two ways, both scoped +/// to iOS 26 and earlier because the rebuild misreads iOS 27's keyboard +/// frames: the remote `ios-keyboard-dock-rebuild-revert` kill switch, and the +/// DEBUG-only local override (Settings > Developer, or the UI-test forces). +/// Hosts evaluate this once at mount, so any input change applies when the +/// workspace is reopened. +public struct TerminalKeyboardDockPathSelection: Sendable, Equatable { + /// The runtime OS major version (e.g. 26, 27). + public let osMajorVersion: Int + /// The remote kill switch reverting iOS ≤26 to the rebuilt path. + public let remoteRebuildRevert: Bool + /// DEBUG-only force pinning the legacy path regardless of OS. + public let debugForceLegacy: Bool + /// DEBUG-only force pinning the rebuilt path on iOS ≤26. + public let debugForceRebuild: Bool + + /// Creates a selection from the runtime inputs a host snapshots at mount. + /// + /// - Parameters: + /// - osMajorVersion: The runtime OS major version. + /// - remoteRebuildRevert: The remote kill switch value. + /// - debugForceLegacy: DEBUG-only legacy pin (UI-test env force). + /// - debugForceRebuild: DEBUG-only rebuild pin (UI-test env force or + /// the Settings > Developer override). + public init( + osMajorVersion: Int, + remoteRebuildRevert: Bool, + debugForceLegacy: Bool = false, + debugForceRebuild: Bool = false + ) { + self.osMajorVersion = osMajorVersion + self.remoteRebuildRevert = remoteRebuildRevert + self.debugForceLegacy = debugForceLegacy + self.debugForceRebuild = debugForceRebuild + } + + /// Whether the host uses the legacy notification+transform dock path. + public var usesLegacyPath: Bool { + if debugForceLegacy { return true } + // The rebuild is unverified on iOS 27+ and misreads its keyboard + // frames; no override may route those OS versions to it. + if osMajorVersion >= 27 { return true } + if debugForceRebuild { return false } + return !remoteRebuildRevert + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardSeatSelection.swift b/Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardSeatSelection.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardSeatSelection.swift rename to Packages/Shared/CmuxMobileTerminalKit/Sources/CmuxMobileTerminalKit/TerminalKeyboardSeatSelection.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/ScrollTopRevealResolutionTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ScrollTopRevealResolutionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ScrollTopRevealResolutionTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/ScrollTopRevealResolutionTests.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/TerminalInputSessionReducerTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputSessionReducerTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputSessionReducerTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalInputSessionReducerTests.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/TerminalKeyboardDockEndpointsTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardDockEndpointsTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardDockEndpointsTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardDockEndpointsTests.swift diff --git a/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardDockPathSelectionTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardDockPathSelectionTests.swift new file mode 100644 index 000000000000..ce144f56178e --- /dev/null +++ b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardDockPathSelectionTests.swift @@ -0,0 +1,66 @@ +import Testing + +@testable import CmuxMobileTerminalKit + +@Suite("Keyboard dock path selection") +struct TerminalKeyboardDockPathSelectionTests { + @Test("legacy is the default on every OS") + func legacyIsDefault() { + for os in [18, 26, 27, 28] { + let selection = TerminalKeyboardDockPathSelection( + osMajorVersion: os, + remoteRebuildRevert: false + ) + #expect(selection.usesLegacyPath) + } + } + + @Test("remote revert routes iOS 26 and earlier to the rebuild") + func remoteRevertSelectsRebuildOnOldOS() { + for os in [18, 26] { + let selection = TerminalKeyboardDockPathSelection( + osMajorVersion: os, + remoteRebuildRevert: true + ) + #expect(!selection.usesLegacyPath) + } + } + + @Test("no override may route iOS 27+ to the rebuild") + func iOS27NeverReverts() { + for os in [27, 28] { + let remote = TerminalKeyboardDockPathSelection( + osMajorVersion: os, + remoteRebuildRevert: true + ) + #expect(remote.usesLegacyPath) + let debug = TerminalKeyboardDockPathSelection( + osMajorVersion: os, + remoteRebuildRevert: false, + debugForceRebuild: true + ) + #expect(debug.usesLegacyPath) + } + } + + @Test("debug rebuild force wins over the remote default on iOS 26") + func debugRebuildForceSelectsRebuild() { + let selection = TerminalKeyboardDockPathSelection( + osMajorVersion: 26, + remoteRebuildRevert: false, + debugForceRebuild: true + ) + #expect(!selection.usesLegacyPath) + } + + @Test("debug legacy force wins over every rebuild input") + func debugLegacyForceWins() { + let selection = TerminalKeyboardDockPathSelection( + osMajorVersion: 26, + remoteRebuildRevert: true, + debugForceLegacy: true, + debugForceRebuild: true + ) + #expect(selection.usesLegacyPath) + } +} diff --git a/Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardSeatSelectionTests.swift b/Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardSeatSelectionTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardSeatSelectionTests.swift rename to Packages/Shared/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalKeyboardSeatSelectionTests.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 e85173059a81..97132fce9029 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"), .package(path: "../CmuxMobileDiagnostics"), ], targets: [ 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/CmxNetworkByteTransportFactory.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransportFactory.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkConnectionEvent.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkConnectionEvent.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkConnectionEvent.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkConnectionEvent.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkDiagnosticFailure.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkDiagnosticFailure.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkDiagnosticFailure.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkDiagnosticFailure.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/CmxPreparingTailscaleByteTransport.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxPreparingTailscaleByteTransport.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteAuthority.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteProof.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleRouteReadiness.swift diff --git a/Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleTransportBinding.swift b/Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleTransportBinding.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleTransportBinding.swift rename to Packages/Shared/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxTailscaleTransportBinding.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/CmxNetworkByteTransportFactorySecurityTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkByteTransportFactorySecurityTests.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/CmxNetworkDiagnosticFailureTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkDiagnosticFailureTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkDiagnosticFailureTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxNetworkDiagnosticFailureTests.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/CmxTailscaleRouteProofTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteProofTests.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/CmxTailscaleRouteReadinessTests.swift diff --git a/Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift b/Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift similarity index 100% rename from Packages/iOS/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.swift rename to Packages/Shared/CmuxMobileTransport/Tests/CmuxMobileTransportTests/ManualClock.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.resolved b/Packages/iOS/CmuxAgentChatUI/Package.resolved index d5d0d404b1a7..302af0f17297 100644 --- a/Packages/iOS/CmuxAgentChatUI/Package.resolved +++ b/Packages/iOS/CmuxAgentChatUI/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "360573bfc5c7f7a8fdde3df27bac8472e0c3938aa9fb7f8bd43b1def104914e8", + "originHash" : "bf12a0a57a2709d6740e63e16ca3919cf022ec6d82ec32f2138cb5dab110e65d", "pins" : [ { "identity" : "highlightr", diff --git a/Packages/iOS/CmuxAgentChatUI/Package.swift b/Packages/iOS/CmuxAgentChatUI/Package.swift index 080c8b14ecb9..113fe6262179 100644 --- a/Packages/iOS/CmuxAgentChatUI/Package.swift +++ b/Packages/iOS/CmuxAgentChatUI/Package.swift @@ -18,7 +18,7 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), .package(path: "../../Shared/CmuxAgentChat"), - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileSupport"), .package(path: "../CmuxMobileToast"), .package( url: "https://github.com/raspu/Highlightr.git", 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/CmuxMobileBrowserStream/Package.swift b/Packages/iOS/CmuxMobileBrowserStream/Package.swift index 021c3cb110d5..d434a496c99a 100644 --- a/Packages/iOS/CmuxMobileBrowserStream/Package.swift +++ b/Packages/iOS/CmuxMobileBrowserStream/Package.swift @@ -13,7 +13,7 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileSupport"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobileShellUI/Package.resolved b/Packages/iOS/CmuxMobileShellUI/Package.resolved index 8dfa8c37170b..f02667b6ea3d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.resolved +++ b/Packages/iOS/CmuxMobileShellUI/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8e4d600dd748d7cf440352cd9cd6219c40b399f51b2f821c0c4ddf4fae27d332", + "originHash" : "f0458a54572c6f772e41a45dfdeea436164f1e7220fd83adbfa1adbc39ff93aa", "pins" : [ { "identity" : "highlightr", diff --git a/Packages/iOS/CmuxMobileShellUI/Package.swift b/Packages/iOS/CmuxMobileShellUI/Package.swift index edc33b47a93a..d9cce9fd859f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.swift +++ b/Packages/iOS/CmuxMobileShellUI/Package.swift @@ -23,17 +23,17 @@ let package = Package( .package(path: "../CmuxMobileBrowserStream"), .package(path: "../CmuxMobileCamera"), .package(path: "../CmuxMobileChanges"), - .package(path: "../CmuxMobileDiagnostics"), - .package(path: "../CmuxMobilePairedMac"), - .package(path: "../CmuxMobileRPC"), - .package(path: "../CmuxMobileShell"), - .package(path: "../CmuxMobileShellModel"), + .package(path: "../../Shared/CmuxMobileDiagnostics"), + .package(path: "../../Shared/CmuxMobilePairedMac"), + .package(path: "../../Shared/CmuxMobileRPC"), + .package(path: "../../Shared/CmuxMobileShell"), + .package(path: "../../Shared/CmuxMobileShellModel"), .package(path: "../CmuxMobileSimulatorStream"), .package(path: "../../Shared/CmuxSimulatorStreamKit"), - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileSupport"), .package(path: "../CmuxMobileTerminal"), + .package(path: "../../Shared/CmuxMobileTerminalKit"), .package(path: "../CmuxMobileToast"), - .package(path: "../CmuxMobileTerminalKit"), .package(path: "../CmuxMobileWorkspace"), .package(path: "../../../vendor/stack-auth-swift-sdk-prerelease"), ], diff --git a/Packages/iOS/CmuxMobileSimulatorStream/Package.swift b/Packages/iOS/CmuxMobileSimulatorStream/Package.swift index aa0e40f4f550..6b34633b618c 100644 --- a/Packages/iOS/CmuxMobileSimulatorStream/Package.swift +++ b/Packages/iOS/CmuxMobileSimulatorStream/Package.swift @@ -19,7 +19,7 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CmuxSimulatorStreamKit"), .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../CmuxMobileRPC"), + .package(path: "../../Shared/CmuxMobileRPC"), ], targets: [ .target( diff --git a/Packages/iOS/CmuxMobileTerminal/Package.swift b/Packages/iOS/CmuxMobileTerminal/Package.swift index 093382a492df..7a192e7921a9 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/CmuxMobileToast/Package.swift b/Packages/iOS/CmuxMobileToast/Package.swift index 7bd9faae070c..eb42f9679e6e 100644 --- a/Packages/iOS/CmuxMobileToast/Package.swift +++ b/Packages/iOS/CmuxMobileToast/Package.swift @@ -16,7 +16,7 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), - .package(path: "../CmuxMobileSupport"), + .package(path: "../../Shared/CmuxMobileSupport"), ], targets: [ .target( 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/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift index 12a5e864c25b..6407a6c12fc8 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift @@ -75,6 +75,12 @@ extension ControlCommandCoordinator { return context?.controlMobileTaskAttachmentUpload(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() + 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 e2c845c91c88..aaad4d84a173 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift @@ -141,6 +141,30 @@ 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 + + /// `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 } public extension ControlMobileHostContext { diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 8263652bfdb4..f16b1b039ea4 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -631,4 +631,7 @@ extension ControlMobileHostContext { func controlMobileTerminalPaste(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } func controlMobileTaskAttachmentUpload(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/Package.swift b/Packages/macOS/CmuxHive/Package.swift new file mode 100644 index 000000000000..edf6f42f2af4 --- /dev/null +++ b/Packages/macOS/CmuxHive/Package.swift @@ -0,0 +1,61 @@ +// 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: "../../Shared/CmuxMobilePairedMac"), + .package(path: "../../Shared/CmuxMobileRPC"), + .package(path: "../../Shared/CmuxMobileShell"), + .package(path: "../../Shared/CmuxMobileShellModel"), + .package(path: "../../Shared/CmuxMobileTerminalKit"), + .package(path: "../../Shared/CmuxMobileTransport"), + ], + targets: [ + .target( + name: "CmuxHive", + dependencies: [ + "CMUXMobileCore", + "CmuxMobilePairedMac", + "CmuxMobileRPC", + "CmuxMobileShell", + "CmuxMobileShellModel", + "CmuxMobileTerminalKit", + "CmuxMobileTransport", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + .testTarget( + name: "CmuxHiveTests", + dependencies: [ + "CmuxHive", + "CMUXMobileCore", + "CmuxMobilePairedMac", + "CmuxMobileRPC", + "CmuxMobileShell", + "CmuxMobileShellModel", + "CmuxMobileTransport", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + .enableUpcomingFeature("InternalImportsByDefault"), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxHive/README.md b/Packages/macOS/CmuxHive/README.md new file mode 100644 index 000000000000..0e5a210d9859 --- /dev/null +++ b/Packages/macOS/CmuxHive/README.md @@ -0,0 +1,44 @@ +# CmuxHive + +Mac-to-Mac directory, pairing, and viewer-session state. App composition injects +registry, presence, scoped pairing storage, transport, and deadline dependencies; +the package tests do not launch the application. + +## Identity and route selection + +Device identity uses `cmxCanonicalDeviceID` from `CMUXMobileCore`: UUID casing is +canonical, while opaque non-UUID identifiers retain their exact bytes. Registry, +pairing, presence updates, and viewer ownership use this same identity contract. + +`HiveViewerRoutePolicy(allowsLoopbackRoutes:)` is shared by row presentation, +pairing, and the viewer runtime. Release policy excludes development loopback +routes. A supported route kind is not authenticated peer admission; production +Tailscale admission remains fail-closed. + +## Testing lifecycle ownership + +Mirror route replacement has its own cancellation owner, independent of the +obsolete observer it tears down. The application transfers mirror ownership +before teardown and cancels pending replacements on detach or window close. + +The package exposes an injected readiness deadline so tests can deliver events +without polling or settling sleeps: + +```swift +@Test @MainActor func firstWorkspaceIsReady() async { + let readiness = HiveMirrorWorkspaceReadiness() + let deadline = AsyncStream.makeStream() + defer { readiness.finish(); deadline.continuation.finish() } + let workspaceID = UUID() + readiness.publish(workspaceID) + let result = await readiness.wait { + for await _ in deadline.stream { break } + } + #expect(result == workspaceID) +} +``` + +Run `swift test` in this package on a leased remote Mac, or use the hosted +`swift-package-tests` CI job. These tests cover the domain and cancellation +seams; live two-Mac listing, viewing, input, reconnect, and native focus require +separate authenticated end-to-end verification. 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/HiveComposition.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift new file mode 100644 index 000000000000..67f97780b810 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComposition.swift @@ -0,0 +1,119 @@ +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: { @Sendable attempt in + await HiveReconnectBackoff(maximumSeconds: 60).delay(attempt: 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 new file mode 100644 index 000000000000..7bdf7657651d --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputer.swift @@ -0,0 +1,105 @@ +public import CMUXMobileCore +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 identity, with UUID casing canonicalized. + /// Opaque non-UUID identities retain their exact bytes. + 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 + /// Whether the authenticated device-registry response belongs to this + /// Stack user. Local-only rows default to `true` because they were created + /// from an explicit pairing action rather than a team listing. + public var isOwnedByCurrentUser: 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] + private let viewerRoutePolicy: HiveViewerRoutePolicy + + 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. + /// - Parameter viewerRoutePolicy: The same route policy used for pairing and dialing. + public init( + deviceID: String, + displayName: String, + platform: String?, + isThisComputer: Bool, + isPaired: Bool, + isOwnedByCurrentUser: Bool = true, + presence: HiveComputerPresence, + buildLabel: String? = nil, + instances: [HiveComputerInstance] = [], + viewerRoutePolicy: HiveViewerRoutePolicy = .init(allowsLoopbackRoutes: false) + ) { + self.deviceID = cmxCanonicalDeviceID(deviceID) + self.displayName = displayName + self.platform = platform + self.isThisComputer = isThisComputer + self.isPaired = isPaired + self.isOwnedByCurrentUser = isOwnedByCurrentUser + self.presence = presence + self.buildLabel = buildLabel + self.instances = instances + self.viewerRoutePolicy = viewerRoutePolicy + } + + /// 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 supported attach routes of the freshest online compatible instance, + /// or the freshest compatible offline instance when none is online. + public var bestPairingRoutes: (routes: [CmxAttachRoute], instanceTag: String?)? { + let candidates = instances.filter { instance in + instance.routes.contains { viewerRoutePolicy.supports($0) } + } + 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 (viewerRoutePolicy.supportedRoutes(best.routes), best.tag) + } + + /// Whether at least one advertised instance has a route the current Mac + /// viewer runtime knows how to select (Tailscale, or DEBUG loopback). + public var hasViewerSupportedRoute: Bool { + instances.contains { instance in + instance.routes.contains { route in + viewerRoutePolicy.supports(route) + } + } + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory+Pairing.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory+Pairing.swift new file mode 100644 index 000000000000..1ad1d310ceef --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory+Pairing.swift @@ -0,0 +1,225 @@ +internal import CMUXMobileCore +internal import CmuxMobilePairedMac +internal import CmuxMobileShell +internal import CmuxMobileShellModel +public import Foundation + +/// Pairing mutations share the directory’s account generation and refresh path. +extension 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 { + let deviceID = cmxCanonicalDeviceID(deviceID) + let scope = await scopeProvider() + guard scope.stackUserID != nil else { return .storeFailed } + 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 + } + guard let computer = computers.first(where: { $0.deviceID == deviceID }), + computer.isPairableHost, + computer.isOwnedByCurrentUser, + computer.hasViewerSupportedRoute 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, + scope: scope + ) + } + + /// 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 scope = await scopeProvider() + guard scope.stackUserID != nil, loadedScope == scope else { return .codeNotFound } + let claimTime = now() + let matches = registryDevices.flatMap { device in + device.instances + // 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 + // team is ambiguous and reports not-found rather than guessing. + guard matches.count == 1, let match = matches.first else { return .codeNotFound } + guard match.device.isOwnedByCurrentUser, + match.instance.routes.contains(where: { viewerRoutePolicy.supports($0) }) else { + return .noRoutes + } + guard match.device.isControllableHost else { return .noRoutes } + guard cmxCanonicalDeviceID(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, + 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() + guard scope.stackUserID != nil else { return .accountMismatch } + if loadedScope == nil { + _ = activateScope(scope) + } else if loadedScope != scope { + await refresh() + guard loadedScope == scope else { return .accountMismatch } + } + 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 !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 + } + macDeviceID = synthesized + } else { + macDeviceID = ticket.macDeviceID + } + guard cmxCanonicalDeviceID(macDeviceID) != ownDeviceID else { + return .loopbackRejected + } + return await persistPairing( + macDeviceID: macDeviceID, + displayName: ticket.macDisplayName ?? Self.endpointLabel(for: ticket.routes), + routes: ticket.routes, + instanceTag: nil, + scope: scope + ) + } + } + + /// 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 deviceID = cmxCanonicalDeviceID(deviceID) + let scope = await scopeProvider() + let generation = scopeGeneration + guard scope.stackUserID != nil, + loadedScope == scope, + await isCurrentScope(scope, generation: generation) else { + return false + } + do { + try await pairedStore.remove( + macDeviceID: deviceID, + stackUserID: scope.stackUserID, + teamID: scope.teamID + ) + } catch { + return false + } + guard await isCurrentScope(scope, generation: generation) else { return false } + await reloadPairedRecords(scope: scope) + guard await isCurrentScope(scope, generation: generation) else { return false } + rebuild() + return true + } + + private func persistPairing( + macDeviceID: String, + displayName: String?, + routes: [CmxAttachRoute], + instanceTag: String?, + scope: HiveAccountScope + ) async -> HivePairOutcome { + let macDeviceID = cmxCanonicalDeviceID(macDeviceID) + let routes = viewerRoutePolicy.supportedRoutes(routes) + 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, + displayName: displayName, + routes: routes, + instanceTag: instanceTag, + markActive: false, + stackUserID: scope.stackUserID, + teamID: scope.teamID, + now: now() + ) + } 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/HiveComputerDirectory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift new file mode 100644 index 000000000000..848581634287 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerDirectory.swift @@ -0,0 +1,431 @@ +internal import CMUXMobileCore +public import CmuxMobilePairedMac +public import CmuxMobileShell +internal 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 let pairedStore: any MobilePairedMacStoring + @ObservationIgnored private let presence: (any PresenceSubscribing)? + @ObservationIgnored let ownDeviceID: String + @ObservationIgnored let scopeProvider: @Sendable () async -> HiveAccountScope + @ObservationIgnored let linkDecoder: HivePairingLinkDecoder + @ObservationIgnored let viewerRoutePolicy: HiveViewerRoutePolicy + @ObservationIgnored 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(set) 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(set) var loadedScope: HiveAccountScope? + @ObservationIgnored private(set) var scopeGeneration = 0 + @ObservationIgnored private var listeners: [UUID: AsyncStream<[HiveComputer]>.Continuation] = [:] + @ObservationIgnored private var deviceListeners: + [String: [UUID: AsyncStream.Continuation]] = [:] + @ObservationIgnored private var presenceTask: Task? + @ObservationIgnored private var presenceGeneration = 0 + @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored private var refreshRequestID: UUID? + + /// 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 = cmxCanonicalDeviceID(ownDeviceID) + self.scopeProvider = scopeProvider + self.linkDecoder = linkDecoder + let routePolicy = HiveViewerRoutePolicy(allowsLoopbackRoutes: linkDecoder.allowsLoopbackRoutes) + self.viewerRoutePolicy = routePolicy + self.now = now + self.presenceRetryDelay = presenceRetryDelay + self.rowBuilder = HiveComputerRowBuilder(ownDeviceID: ownDeviceID, viewerRoutePolicy: routePolicy) + } + + // 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) + } + } + if !listeners.isEmpty { + startPresenceIfNeeded() + } + return stream + } + + /// A device-scoped stream for route/presence changes affecting one + /// computer. Unlike ``updates()``, it never allocates or broadcasts the + /// full team snapshot to a mirror/window listener. + /// + /// - Parameter deviceID: The registry device id to observe. + /// - Returns: The current row, then only that row's changes; `nil` means + /// the device disappeared from the current account scope. + public func updates(for deviceID: String) -> AsyncStream { + let id = UUID() + let key = cmxCanonicalDeviceID(deviceID) + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) + deviceListeners[key, default: [:]][id] = continuation + continuation.yield(computers.first { + $0.deviceID == key + }) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.removeDeviceListener(id: id, deviceID: key) + } + } + startPresenceIfNeeded() + return stream + } + + private func removeListener(id: UUID) { + listeners.removeValue(forKey: id) + stopPresenceIfUnused() + } + + private func removeDeviceListener(id: UUID, deviceID: String) { + deviceListeners[deviceID]?.removeValue(forKey: id) + if deviceListeners[deviceID]?.isEmpty == true { + deviceListeners.removeValue(forKey: deviceID) + } + stopPresenceIfUnused() + } + + private func stopPresenceIfUnused() { + if listeners.isEmpty && deviceListeners.isEmpty { + presenceGeneration &+= 1 + presenceTask?.cancel() + presenceTask = nil + invalidatePresence() + } + } + + // 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 { + let generationBeforeRead = scopeGeneration + let scope = await scopeProvider() + guard generationBeforeRead == scopeGeneration || loadedScope == scope else { return } + let generation = activateScope(scope) + while !Task.isCancelled { + guard generation == scopeGeneration, loadedScope == scope else { return } + if refreshTask == nil { + refreshRequestID = UUID() + refreshTask = Task { [weak self] in + await self?.performRefresh(scope: scope, generation: generation) + } + } + guard let task = refreshTask else { return } + let requestID = refreshRequestID + let completedGeneration = await task.value + if refreshRequestID == requestID { + refreshTask = nil + refreshRequestID = nil + } + guard generation == scopeGeneration, loadedScope == scope else { return } + guard let completedGeneration else { return } + if completedGeneration == generation { + _ = await isCurrentScope(scope, generation: generation) + return + } + // An old account's completed request is not a refresh of the + // current account. Join or start its one shared successor. + } + } + + private func performRefresh(scope: HiveAccountScope, generation: Int) async -> Int { + guard await isCurrentScope(scope, generation: generation) else { return generation } + isRefreshing = true + defer { isRefreshing = false } + guard scope.stackUserID != nil else { + clearLoadedSources() + rebuild() + return generation + } + switch await registry.listDevices() { + case .ok(let devices): + guard await isCurrentScope(scope, generation: generation) else { return generation } + registryDevices = devices + registryByID = Dictionary( + devices.map { (cmxCanonicalDeviceID($0.deviceId), $0) }, + uniquingKeysWith: { first, _ in first } + ) + lastRefreshFailed = false + case .authRejected: + guard await isCurrentScope(scope, generation: generation) else { return generation } + registryDevices = [] + registryByID = [:] + lastRefreshFailed = false + case .transientFailure: + guard await isCurrentScope(scope, generation: generation) else { return generation } + lastRefreshFailed = true + } + guard await isCurrentScope(scope, generation: generation) else { return generation } + await reloadPairedRecords(scope: scope) + guard await isCurrentScope(scope, generation: generation) else { return generation } + rebuild() + return generation + } + + func reloadPairedRecords(scope: HiveAccountScope) async { + guard loadedScope == scope else { return } + let generation = scopeGeneration + guard scope.stackUserID != nil else { + pairedRecords = [] + pairedByID = [:] + pairedRecordsByID = [:] + return + } + do { + let records = try await pairedStore.loadAll( + stackUserID: scope.stackUserID, + teamID: scope.teamID + ) + guard await isCurrentScope(scope, generation: generation) else { return } + pairedRecords = records + pairedByID = rowBuilder.indexPairedRecords(pairedRecords) + pairedRecordsByID = Dictionary(grouping: pairedRecords) { cmxCanonicalDeviceID($0.macDeviceID) } + } catch { + // Keep the previous local list; a store read failure must not + // wipe rows the registry no longer needs to confirm. + } + } + + /// 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 + } + + func activateScope(_ scope: HiveAccountScope) -> Int { + guard loadedScope != scope else { return scopeGeneration } + // A scoped viewer must never survive an account/team transition. End + // each device stream before replacing the rows so its mirror/window + // owner tears down the old team's RPC session instead of retaining it. + let deviceContinuations = deviceListeners.values.flatMap { $0.values } + deviceListeners.removeAll() + for continuation in deviceContinuations { + continuation.yield(nil) + continuation.finish() + } + 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) + } + if !listeners.isEmpty { + startPresenceIfNeeded() + } + return scopeGeneration + } + + func isCurrentScope(_ scope: HiveAccountScope, generation: Int) async -> Bool { + guard generation == scopeGeneration, loadedScope == scope else { return false } + let latest = await scopeProvider() + guard generation == scopeGeneration, loadedScope == scope else { return false } + guard latest == scope else { + _ = activateScope(latest) + return false + } + return true + } + + // MARK: - Presence + + 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, generation: generation) + } + } + + private func runPresenceLoop( + presence: any PresenceSubscribing, + generation: Int + ) async { + var consecutiveFailures = 0 + 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(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. + await presenceRetryDelay(consecutiveFailures) + } + } + + // MARK: - Merge + + private func invalidatePresence() { + presenceMap = PresenceMap() + rebuild() + } + + func rebuild(affectedDeviceIDs: Set? = nil) { + let previousComputers = computers + let changedIDs: Set + if let affectedDeviceIDs { + changedIDs = Set(affectedDeviceIDs.map(cmxCanonicalDeviceID)) + for deviceID in changedIDs { + 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 { + changedIDs = Set( + (previousComputers.map(\.deviceID) + + registryDevices.map(\.deviceId) + + pairedRecords.map(\.macDeviceID)).map(cmxCanonicalDeviceID) + ) + computers = rowBuilder.makeRows( + registry: registryDevices, + paired: pairedRecords, + presence: presenceMap + ) + } + if previousComputers != computers { + for (_, continuation) in listeners { + continuation.yield(computers) + } + } + for deviceID in changedIDs { + let row = computers.first { + $0.deviceID == deviceID + } + let key = cmxCanonicalDeviceID(deviceID) + deviceListeners[key]?.values.forEach { continuation in + continuation.yield(row) + } + } + } + + 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] + } + } + +} 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/HiveComputerRowBuilder.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerRowBuilder.swift new file mode 100644 index 000000000000..d564d590f419 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveComputerRowBuilder.swift @@ -0,0 +1,188 @@ +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 struct HiveComputerRowBuilder: Sendable { + /// The local device id used to mark the This Mac row. + public let ownDeviceID: String + private let viewerRoutePolicy: HiveViewerRoutePolicy + + /// Creates a row builder for one local device and its runtime route policy. + /// - Parameters: + /// - ownDeviceID: This computer’s stable device identity. + /// - viewerRoutePolicy: Route kinds accepted by the corresponding viewer runtime. + public init( + ownDeviceID: String, + viewerRoutePolicy: HiveViewerRoutePolicy = .init(allowsLoopbackRoutes: false) + ) { + self.ownDeviceID = cmxCanonicalDeviceID(ownDeviceID) + self.viewerRoutePolicy = viewerRoutePolicy + } + + /// 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) { cmxCanonicalDeviceID($0.macDeviceID) } + let registryByID = Dictionary( + registry.map { (cmxCanonicalDeviceID($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 + let deviceID = cmxCanonicalDeviceID(record.macDeviceID) + guard let existing = result[deviceID] else { + result[deviceID] = record + return + } + let preferRecord = (record.isActive && !existing.isActive) + || (record.isActive == existing.isActive && record.lastSeenAt > existing.lastSeenAt) + if preferRecord { + result[deviceID] = 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 = cmxCanonicalDeviceID(registry?.deviceId ?? paired?.macDeviceID ?? "") + let instances: [HiveComputerInstance] + if let registry, !registry.instances.isEmpty { + 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 if !pairedRecords.isEmpty { + instances = pairedRecords.map { record in + HiveComputerInstance( + tag: record.instanceTag ?? "default", + routes: record.routes, + lastSeenAt: record.lastSeenAt, + isOnline: false + ) + } + } else if let paired { + instances = [ + HiveComputerInstance( + tag: paired.instanceTag ?? "default", + routes: paired.routes, + lastSeenAt: paired.lastSeenAt, + isOnline: false + ) + ] + } else { + instances = [] + } + 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, + isOwnedByCurrentUser: registry?.isOwnedByCurrentUser ?? true, + presence: presenceState( + for: deviceID, + presence: presence, + fallbackLastSeen: fallbackLastSeen + ), + buildLabel: presence.deviceSummary(deviceId: deviceID)?.buildLabel, + instances: instances, + viewerRoutePolicy: viewerRoutePolicy + ) + } + + /// 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/HiveMirrorRebindCoordinator.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveMirrorRebindCoordinator.swift new file mode 100644 index 000000000000..33ee8dc54a62 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveMirrorRebindCoordinator.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Owns route replacements independently of the obsolete mirrors they tear down. +@MainActor +public final class HiveMirrorRebindCoordinator { + private struct Entry { + let requestID: UUID + let task: Task + } + private var entries: [Key: Entry] = [:] + + /// Creates an empty route-replacement owner. + public init() {} + + /// Starts one owned replacement, cancelling any older replacement for that key. + /// - Parameters: + /// - key: The device/window identity whose mirror is being replaced. + /// - operation: Cancellation-aware teardown and reattachment work. + /// - Returns: The replacement task, independent of its obsolete route observer. + @discardableResult + public func rebind( + key: Key, + operation: @escaping @MainActor () async -> Void + ) -> Task { + cancel(key: key) + let requestID = UUID() + let task = Task { @MainActor [weak self] in + defer { self?.finish(key: key, requestID: requestID) } + guard !Task.isCancelled else { return } + await operation() + } + entries[key] = Entry(requestID: requestID, task: task) + return task + } + + /// Cancels the pending replacement for one device/window identity. + /// - Parameter key: The identity being detached or closed. + public func cancel(key: Key) { + entries.removeValue(forKey: key)?.task.cancel() + } + + /// Cancels matching replacements, including ones between old and new mirrors. + /// - Parameter predicate: Selects a device's windows, or all entries when omitted. + public func cancelAll(matching predicate: (Key) -> Bool = { _ in true }) { + for key in entries.keys.filter(predicate) { cancel(key: key) } + } + + private func finish(key: Key, requestID: UUID) { + guard entries[key]?.requestID == requestID else { return } + entries.removeValue(forKey: key) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveMirrorWorkspaceReadiness.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveMirrorWorkspaceReadiness.swift new file mode 100644 index 000000000000..575a7fcbf0b9 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveMirrorWorkspaceReadiness.swift @@ -0,0 +1,40 @@ +public import Foundation + +/// Delivers the first reconciled workspace for one mirror attachment without polling. +@MainActor +public final class HiveMirrorWorkspaceReadiness { + private let ready = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + + /// Creates the one-shot readiness channel owned by a mirror attachment. + public init() {} + + /// Publishes a workspace only after reconciliation has installed it locally. + /// - Parameter workspaceID: The ready local workspace, or nil to clear an obsolete snapshot. + public func publish(_ workspaceID: UUID?) { + ready.continuation.yield(workspaceID) + } + + /// Ends the pending wait when its mirror is torn down. + public func finish() { ready.continuation.finish() } + + /// Waits for a reconciled workspace, teardown, cancellation, or an injected deadline. + /// - Parameter timeout: A cancellable deadline; tests control it without wall-clock sleeps. + /// - Returns: A ready workspace identity, or nil when the attachment ends first. + public func wait(timeout: @escaping @Sendable () async -> Void) async -> UUID? { + let stream = ready.stream + return await withTaskGroup(of: UUID?.self) { group in + group.addTask { + for await workspaceID in stream { + if let workspaceID { return workspaceID } + } + return nil + } + group.addTask { + await timeout() + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift new file mode 100644 index 000000000000..7e566416e166 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HivePairOutcome.swift @@ -0,0 +1,25 @@ +/// 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 + /// 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 + /// 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). + 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/Sources/CmuxHive/HiveReconnectBackoff.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift new file mode 100644 index 000000000000..3d717105ff4a --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveReconnectBackoff.swift @@ -0,0 +1,32 @@ +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. + @concurrent + public func delay(attempt: Int) async { + let seconds = min(maximumSeconds, pow(2.0, Double(min(max(attempt - 1, 0), 6)))) + // 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/HiveRemoteHostEvents.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteHostEvents.swift new file mode 100644 index 000000000000..6d8cf817d5df --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteHostEvents.swift @@ -0,0 +1,58 @@ +import CmuxMobileRPC +import Foundation + +/// Owns one host subscription shared by workspace and render-grid consumers. +@MainActor +final class HiveRemoteHostEvents { + private let client: MobileCoreRPCClient + private let streamID = UUID().uuidString + private var subscribed = false + private var generation = 0 + private var pending: Task? + + init(client: MobileCoreRPCClient) { + self.client = client + } + + /// Coalesces concurrent setup; a stable ID makes recovery idempotent on the host. + func ensureSubscribed() async throws { + guard !subscribed else { return } + let task: Task + if let pending { + task = pending + } else { + generation &+= 1 + let client = self.client + let streamID = self.streamID + task = Task { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.events.subscribe", + params: [ + "stream_id": streamID, + "topics": ["workspace.updated", "terminal.render_grid"], + ] + ) + _ = try await client.sendRequest(request) + } + pending = task + } + let attempt = generation + do { + try await task.value + guard attempt == generation else { throw CancellationError() } + subscribed = true + pending = nil + } catch { + if attempt == generation { pending = nil } + throw error + } + } + + /// A lost authoritative event stream requires re-admission on the next request. + func invalidate() { + generation &+= 1 + subscribed = false + pending?.cancel() + pending = nil + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift new file mode 100644 index 000000000000..186638655df5 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteMacSession.swift @@ -0,0 +1,448 @@ +public import CMUXMobileCore +public import CmuxMobileRPC +internal import CmuxMobileShellModel +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 + /// 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. + public let requiresHostIdentity: Bool + + /// 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 retryDelay: @Sendable (_ attempt: Int) async -> Void + @ObservationIgnored private let workspaceDecoder: HiveRemoteWorkspaceDecoder + /// Exact compatibility evidence for a legacy Tailscale route, when a + /// locally persisted pre-Iroh pairing supplied it. A missing or mismatched + /// grant intentionally leaves the route fail-closed. + @ObservationIgnored private let legacyTailscaleAuthorizationEvidence: + CmxLegacyTailscaleAuthorizationEvidence? + /// 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 disconnectTask: Task? + @ObservationIgnored private var workspaceRefreshTask: Task? + @ObservationIgnored private var workspaceRefreshPending = false + @ObservationIgnored private var workspaceListeners: + [UUID: AsyncStream<[HiveRemoteWorkspace]>.Continuation] = [:] + + /// 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. + /// - 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. + /// - legacyTailscaleAuthorizationEvidence: Exact local compatibility + /// evidence for one pre-Iroh Tailscale route. Keep `nil` for routes + /// without cryptographic transport admission. + /// - 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], + sourceRoutes: [CmxAttachRoute]? = nil, + retryDelay: @escaping @Sendable (_ attempt: Int) async -> Void, + legacyTailscaleAuthorizationEvidence: CmxLegacyTailscaleAuthorizationEvidence? = nil, + expectedInstanceTag: String? = nil, + requiresHostIdentity: Bool = true + ) { + self.runtime = runtime + self.macDeviceID = cmxCanonicalDeviceID(macDeviceID) + self.displayName = displayName + self.routes = routes + self.sourceRoutes = sourceRoutes ?? routes + self.expectedInstanceTag = expectedInstanceTag + self.requiresHostIdentity = requiresHostIdentity + self.retryDelay = retryDelay + self.workspaceDecoder = HiveRemoteWorkspaceDecoder() + self.legacyTailscaleAuthorizationEvidence = legacyTailscaleAuthorizationEvidence + } + + /// 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, disconnectTask == nil else { return } + phase = .connecting + connectTask = Task { [weak self] in + await self?.runConnect() + self?.connectTask = nil + } + } + + /// Restarts a cached session only when it is no longer usable. + /// + /// Embedded viewers retain one session per paired computer so multiple + /// windows share the same transport. A failed or explicitly disconnected + /// session therefore needs an explicit retry seam; callers must not call + /// ``connect()`` on every lookup because an active session may already be + /// carrying live terminals. + /// + /// - Returns: `true` when a new connection attempt was started. + @discardableResult + public func reconnectIfNeeded() -> Bool { + guard disconnectTask == nil else { return false } + switch phase { + case .idle, .failed: + connect() + return true + case .connecting, .reconnecting, .connected: + return false + } + } + + /// Tear down the session (window closed). + public func disconnect() async { + if let disconnectTask { + await disconnectTask.value + return + } + // Revoke every admission surface before the first suspension. The + // captured resources remain owned until their cancellation drains. + let retiringClient = client + retiringClient?.retire() + client = nil + phase = .idle + renderGridRouter?.stop() + renderGridRouter = nil + let pendingConnect = connectTask + connectTask = nil + let pendingEvents = eventTask + let pendingRefresh = workspaceRefreshTask + pendingConnect?.cancel() + pendingEvents?.cancel() + pendingRefresh?.cancel() + eventTask = nil + workspaceRefreshTask = nil + workspaceRefreshPending = false + let teardown = Task { [weak self] in + defer { self?.disconnectTask = nil } + await retiringClient?.disconnect() + await pendingConnect?.value + await pendingEvents?.value + await pendingRefresh?.value + } + disconnectTask = teardown + await teardown.value + } + + /// Re-fetch the workspace list from the connected Mac. + @discardableResult + public func refreshWorkspaces() async -> Bool { + scheduleWorkspaceRefresh() + return await workspaceRefreshTask?.value ?? false + } + + /// Creates a terminal session using this Mac's shared surface-scoped event + /// router. Returns `nil` unless connected, including throughout teardown. + /// - 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 disconnectTask == nil, phase == .connected, + let client, let renderGridRouter else { return nil } + return HiveRemoteTerminalSession( + client: client, + workspaceID: workspaceID, + terminalID: terminalID, + retryDelay: retryDelay, + renderGridRouter: renderGridRouter + ) + } + + private func scheduleWorkspaceRefresh() { + guard disconnectTask == nil, client != nil else { return } + guard workspaceRefreshTask == nil else { + workspaceRefreshPending = true + return + } + workspaceRefreshTask = Task { [weak self] in + guard let self else { return false } + let succeeded = await self.performWorkspaceRefresh() + self.workspaceRefreshTask = nil + guard !Task.isCancelled else { + self.workspaceRefreshPending = false + return succeeded + } + if self.workspaceRefreshPending { + self.workspaceRefreshPending = false + self.scheduleWorkspaceRefresh() + } + return succeeded + } + } + + 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 false } + setWorkspaces(refreshed) + return true + } catch { + // Keep the stale list; the event loop's stream death drives the + // visible reconnect state. + return false + } + } + + /// 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 { + let supported = Set(runtime.supportedRouteKinds) + let candidates = routes + .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 + } + 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, + legacyTailscaleAuthorizationEvidence: legacyTailscaleAuthorizationEvidence + ) + let hostEvents = HiveRemoteHostEvents(client: candidate) + do { + if requiresHostIdentity { + try await verifyHostIdentity(client: candidate) + } + let workspaces = try await fetchWorkspaces(client: candidate) + // Complete the host-side event subscription before publishing + // `.connected` or handing the shared render router to a + // terminal session. Otherwise a replay can race the first + // `mobile.events.subscribe` request and lose early deltas. + try await hostEvents.ensureSubscribed() + // 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 + } + renderGridRouter?.stop() + renderGridRouter = nil + if let previous = client { await previous.disconnect() } + guard !Task.isCancelled else { + await candidate.disconnect() + return + } + client = candidate + renderGridRouter = HiveRemoteRenderGridRouter(client: candidate, hostEvents: hostEvents) + setWorkspaces(workspaces) + phase = .connected + startEventLoop(client: candidate, hostEvents: hostEvents) + return + } catch { + lastError = error + await candidate.disconnect() + } + } + 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 + /// 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 func fetchWorkspaces(client: MobileCoreRPCClient) async throws -> [HiveRemoteWorkspace] { + let request = try MobileCoreRPCClient.requestData(method: "mobile.workspace.list") + let data = try await client.sendRequest(request) + let decoder = workspaceDecoder + return try await Task.detached(priority: .userInitiated) { + try decoder.decode(data) + }.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.map(cmxCanonicalDeviceID) == macDeviceID, + expectedInstanceTag == nil || status.macInstanceTag == expectedInstanceTag else { + throw HiveRemoteTerminalSessionError.hostIdentityMismatch + } + } + + // MARK: - Events + + private func startEventLoop(client: MobileCoreRPCClient, hostEvents: HiveRemoteHostEvents) { + eventTask?.cancel() + eventTask = Task { [weak self] in + await self?.runEventLoop(client: client, hostEvents: hostEvents) + } + } + + private func runEventLoop(client: MobileCoreRPCClient, hostEvents: HiveRemoteHostEvents) async { + var consecutiveFailures = 0 + while !Task.isCancelled { + // Install and consume the listener before refreshing the snapshot. + // Events arriving during decoding are buffered, never lost in a setup gap. + let stream = await client.subscribe(to: ["workspace.updated"]) + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { @Sendable @MainActor [weak self] in + for await _ in stream { + guard !Task.isCancelled else { return } + self?.scheduleWorkspaceRefresh() + } + } + // Structured cancellation removes the listener even when setup + // or the initial refresh fails before normal stream iteration. + defer { group.cancelAll() } + try await hostEvents.ensureSubscribed() + guard !Task.isCancelled else { throw CancellationError() } + guard await refreshWorkspaces() else { + throw MobileShellConnectionError.connectionClosed + } + guard !Task.isCancelled else { throw CancellationError() } + consecutiveFailures = 0 + phase = .connected + try await group.waitForAll() + } + } catch is CancellationError { + return + } catch { + // A transient setup failure takes the same recovery path as EOF. + } + guard !Task.isCancelled else { return } + hostEvents.invalidate() + 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." + ) + } + + 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..6fb2970facd1 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridDecoder.swift @@ -0,0 +1,31 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation + +/// Decodes the two wire shapes used by remote render-grid events and replay. +actor HiveRemoteRenderGridDecoder { + private let decoder = JSONDecoder() + + /// Decode a wrapped or bare render-grid event. + func decodeFrame(_ payload: Data) -> MobileTerminalRenderGridFrame? { + 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 a replay only when the host's routing identity matches the requesting session. + func decodeReplay( + _ payload: Data, workspaceID: String, surfaceID: String + ) throws -> MobileTerminalReplayResponse { + let envelope = try decoder.decode(HiveRemoteReplayEnvelope.self, from: payload) + guard envelope.workspaceID == workspaceID else { + throw HiveRemoteTerminalSessionError.mismatchedWorkspace + } + guard envelope.surfaceID == surfaceID else { + throw HiveRemoteTerminalSessionError.mismatchedSurface + } + return envelope.response + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift new file mode 100644 index 000000000000..f265038f26f1 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteRenderGridRouter.swift @@ -0,0 +1,165 @@ +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 { + /// An individually cancellable surface subscription. + struct Subscription { + let stream: AsyncStream + let cancel: @MainActor () -> Void + } + + private struct Listener { + let surfaceID: String + let continuation: AsyncStream.Continuation + let onOverflow: () -> Void + } + + private let client: MobileCoreRPCClient + private let hostEvents: HiveRemoteHostEvents + private let decoder = HiveRemoteRenderGridDecoder() + 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 convenience init(client: MobileCoreRPCClient) { + self.init(client: client, hostEvents: HiveRemoteHostEvents(client: client)) + } + + init(client: MobileCoreRPCClient, hostEvents: HiveRemoteHostEvents) { + self.client = client + self.hostEvents = hostEvents + } + + /// 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. + /// - Parameter onOverflow: Called when the bounded stream drops a frame; + /// the owner must request a full replay because render-grid frames are + /// deltas and cannot be safely resumed from a gap. + public func stream( + for surfaceID: String, + onOverflow: @escaping () -> Void = {} + ) -> AsyncStream { + subscription(for: surfaceID, onOverflow: onOverflow).stream + } + + /// Creates a surface stream with an explicit cancellation handle. + /// + /// The handle is required when an initial replay fails before the caller + /// begins iterating the stream; dropping an ``AsyncStream`` value alone + /// does not release the router's continuation. + /// + /// - Parameters: + /// - surfaceID: The terminal surface to route. + /// - onOverflow: Called when the bounded stream drops a frame. + func subscription( + for surfaceID: String, + onOverflow: @escaping () -> Void = {} + ) -> Subscription { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + listeners[id] = Listener( + surfaceID: surfaceID, + continuation: continuation, + onOverflow: onOverflow + ) + continuation.onTermination = { [weak self] _ in + Task { @MainActor [weak self] in + self?.removeListener(id: id) + } + } + startSourceIfNeeded() + return Subscription( + stream: stream, + cancel: { [weak self] in + self?.removeListener(id: id) + } + ) + } + + /// 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 + let hostEvents = self.hostEvents + let decoder = self.decoder + sourceTask = Task { [weak self] in + let source = await client.subscribe(to: ["terminal.render_grid"]) + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { @Sendable @MainActor [weak self] in + for await envelope in source { + guard !Task.isCancelled, + let payload = envelope.payloadJSON else { continue } + let frame = await decoder.decodeFrame(payload) + guard let frame, !Task.isCancelled else { continue } + self?.yield(frame, generation: generation) + } + } + defer { group.cancelAll() } + try await hostEvents.ensureSubscribed() + try await group.waitForAll() + } + } catch { + // The scoped consumer is cancelled even when setup fails. + } + guard !Task.isCancelled else { return } + hostEvents.invalidate() + 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 { + if case .dropped = listener.continuation.yield(frame) { + listener.onOverflow() + } + } + } + + 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) { + guard let listener = listeners.removeValue(forKey: id) else { return } + listener.continuation.finish() + if listeners.isEmpty { + stop() + } + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteReplayEnvelope.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteReplayEnvelope.swift new file mode 100644 index 000000000000..689908294063 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteReplayEnvelope.swift @@ -0,0 +1,21 @@ +import CmuxMobileRPC +import Foundation + +/// Requires host routing identity without changing the mobile replay decoder's compatibility contract. +nonisolated struct HiveRemoteReplayEnvelope: Decodable, Sendable { + let workspaceID: String + let surfaceID: String + let response: MobileTerminalReplayResponse + + private enum CodingKeys: String, CodingKey { + case workspaceID = "workspace_id" + case surfaceID = "surface_id" + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + workspaceID = try container.decode(String.self, forKey: .workspaceID) + surfaceID = try container.decode(String.self, forKey: .surfaceID) + response = try MobileTerminalReplayResponse(from: decoder) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift new file mode 100644 index 000000000000..d700923f7c6b --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSession.swift @@ -0,0 +1,495 @@ +internal import CMUXMobileCore +public import CmuxMobileRPC +public import CmuxMobileTerminalKit +public 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 { + private struct FrameSubscription { + let stream: AsyncStream + let cancel: @MainActor () -> Void + } + + /// 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() + /// 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)? + /// 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 renderGridRouter: HiveRemoteRenderGridRouter? + @ObservationIgnored private let retryDelay: @Sendable (_ attempt: Int) async -> Void + @ObservationIgnored private let renderGridDecoder: HiveRemoteRenderGridDecoder + @ObservationIgnored private var attachTask: Task? + @ObservationIgnored private var liveSubscriptionID: UUID? + @ObservationIgnored private var replayTask: Task? + @ObservationIgnored private let replayCoordinator = HiveTerminalReplayCoordinator() + @ObservationIgnored private var replayRetryAttempt = 0 + @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 pendingInputBytes = 0 + @ObservationIgnored private let maximumPendingInputBytes = 64 * 1024 + @ObservationIgnored private var inputGeneration = 0 + + /// 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, + renderGridRouter: HiveRemoteRenderGridRouter? = nil + ) { + self.client = client + self.workspaceID = workspaceID + self.terminalID = terminalID + self.retryDelay = retryDelay + self.renderGridRouter = renderGridRouter + self.renderGridDecoder = HiveRemoteRenderGridDecoder() + } + + /// 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 + liveSubscriptionID = nil + replayTask?.cancel() + replayTask = nil + replayCoordinator.cancel() + frameApplyTask?.cancel() + frameApplyTask = nil + pendingFrames.removeAll(keepingCapacity: true) + frameQueueNeedsReplay = false + inputGeneration &+= 1 + inputTask?.cancel() + inputTask = nil + pendingInput.removeAll(keepingCapacity: true) + pendingInputBytes = 0 + 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). A failed request is + /// retried through the injected reconnect backoff so a known frame gap + /// cannot leave a live terminal permanently stale. + public func refreshReplay() { + guard phase != .idle else { return } + guard !replayCoordinator.isRunning, replayTask == nil else { + frameQueueNeedsReplay = true + return + } + let generation = inputGeneration + replayTask = Task { [weak self] in + guard let self else { return } + defer { + if generation == self.inputGeneration { + self.replayTask = nil + if !Task.isCancelled { self.scheduleReplayIfNeeded() } + } + } + do { + try await self.requestReplay() + self.replayRetryAttempt = 0 + if self.phase == .reattaching, self.liveSubscriptionID != nil { + self.phase = .live + self.startInputWorkerIfNeeded() + } + } catch is CancellationError { + return + } catch { + // A dropped delta is a known consistency failure. Keep the + // replay-required flag set and retry through the injected + // backoff instead of leaving a live session on a gapped grid. + self.frameQueueNeedsReplay = true + self.phase = .reattaching + self.replayRetryAttempt += 1 + let attempt = self.replayRetryAttempt + await self.retryDelay(attempt) + if Task.isCancelled { return } + } + } + } + + // 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) { + // Keep keystrokes typed while the initial replay (or a reconnect's + // replay) is in flight. The worker starts as soon as the session + // reaches `.live`, so attach/re-attach transitions do not drop input. + guard phase == .attaching || phase == .live || phase == .reattaching else { return } + enqueueInput(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 } + var requestStarted = false + do { + let request = try MobileCoreRPCClient.requestData( + method: "mobile.terminal.input", + params: [ + "workspace_id": self.workspaceID, + "surface_id": self.terminalID, + "text": text, + ] + ) + 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. + 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() + self.attachTask = nil + self.attach() + } + return + } + } + } + } + + private func dequeueInput(for generation: Int) -> String? { + guard generation == inputGeneration, phase == .live, !pendingInput.isEmpty else { return nil } + 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 + + private func runAttachLoop() async { + var consecutiveFailures = 0 + while !Task.isCancelled { + let subscriptionID = UUID() + defer { + if liveSubscriptionID == subscriptionID { liveSubscriptionID = nil } + } + let subscription: FrameSubscription + do { + // 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. + subscription = try await subscribeToFrames() + do { + try await requestReplay(afterCurrent: true) + } catch { + subscription.cancel() + throw error + } + guard !Task.isCancelled else { + subscription.cancel() + return + } + consecutiveFailures = 0 + liveSubscriptionID = subscriptionID + phase = .live + startInputWorkerIfNeeded() + } catch is CancellationError { + return + } catch { + consecutiveFailures += 1 + if Task.isCancelled { return } + phase = .reattaching + await retryDelay(consecutiveFailures) + continue + } + for await frame in subscription.stream { + guard !Task.isCancelled else { + subscription.cancel() + return + } + enqueueFrame(frame) + } + // Covers both transport EOF and cancellation before the next + // attach attempt; cancellation is idempotent after normal EOF. + if liveSubscriptionID == subscriptionID { liveSubscriptionID = nil } + subscription.cancel() + // Stream finished: transport died. Re-subscribe + re-replay. + if Task.isCancelled { return } + phase = .reattaching + consecutiveFailures += 1 + await retryDelay(consecutiveFailures) + } + } + + private func subscribeToFrames() async throws -> FrameSubscription { + if let renderGridRouter { + let subscription = renderGridRouter.subscription(for: terminalID) { [weak self] in + self?.renderGridOverflowed() + } + return FrameSubscription(stream: subscription.stream, cancel: subscription.cancel) + } + + 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 { @MainActor [weak self] in + for await envelope in envelopes { + guard !Task.isCancelled, + let payload = envelope.payloadJSON else { continue } + let frame = await decoder.decodeFrame(payload) + guard let frame, frame.surfaceID == terminalID else { continue } + if case .dropped = continuation.yield(frame) { + self?.renderGridOverflowed() + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + return FrameSubscription( + stream: stream, + cancel: { @MainActor in + continuation.finish() + task.cancel() + } + ) + } + + private func requestReplay(afterCurrent: Bool = false) async throws { + let generation = inputGeneration + do { + try await replayCoordinator.run(afterCurrent: afterCurrent) { + try await self.performReplay() + } + } catch { + if generation == inputGeneration, !Task.isCancelled { frameQueueNeedsReplay = true } + throw error + } + if frameQueueNeedsReplay { scheduleReplayIfNeeded() } + else { startFrameApplyIfNeeded() } + } + + private func performReplay() async throws { + frameQueueNeedsReplay = false + let allowingFullReset = phase != .live + let previousApplyTask = frameApplyTask + frameApplyTask = nil + if let previousApplyTask { + await previousApplyTask.value + } + try Task.checkCancellation() + let replayFloor = grid.stateSeq + let request = try MobileCoreRPCClient.requestData( + method: "mobile.terminal.replay", + params: [ + "workspace_id": workspaceID, + "surface_id": terminalID, + ] + ) + let data = try await client.sendRequest(request) + let decoder = renderGridDecoder + let response = try await decoder.decodeReplay( + data, workspaceID: workspaceID, surfaceID: terminalID + ) + 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 + } + let bufferedFrames = pendingFrames + pendingFrames.removeAll(keepingCapacity: true) + if !grid.hasContent || allowingFullReset || frame.stateSeq >= replayFloor { + applyFrame(frame, allowingFullReset: allowingFullReset) + } + for bufferedFrame in bufferedFrames.sorted(by: { $0.stateSeq < $1.stateSeq }) + where bufferedFrame.stateSeq > grid.stateSeq { + applyFrame(bufferedFrame) + } + } + + private func enqueueFrame(_ frame: MobileTerminalRenderGridFrame) { + if pendingFrames.count >= 256 { + pendingFrames.removeAll(keepingCapacity: true) + frameQueueNeedsReplay = true + } + pendingFrames.append(frame) + guard !replayCoordinator.isRunning else { return } + if frameQueueNeedsReplay { + scheduleReplayIfNeeded() + return + } + startFrameApplyIfNeeded() + } + + private func startFrameApplyIfNeeded() { + guard frameApplyTask == nil, + !replayCoordinator.isRunning, + !frameQueueNeedsReplay, + !pendingFrames.isEmpty else { return } + frameApplyTask = Task { [weak self] in + await Task.yield() + guard let self, !Task.isCancelled 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.frameQueueNeedsReplay = true + self.scheduleReplayIfNeeded() + } + for frame in remaining { + self.enqueueFrame(frame) + } + } + } + + private func renderGridOverflowed() { + guard phase != .idle else { return } + frameQueueNeedsReplay = true + scheduleReplayIfNeeded() + } + + private func scheduleReplayIfNeeded() { + guard frameQueueNeedsReplay, phase != .idle, + !replayCoordinator.isRunning, replayTask == nil else { return } + frameQueueNeedsReplay = false + refreshReplay() + } + + 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 } + frameBytesHandler?(bytes) + } + +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift new file mode 100644 index 000000000000..0411abaddd4e --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteTerminalSessionError.swift @@ -0,0 +1,8 @@ +/// Errors that keep an incomplete or misrouted replay from becoming live. +enum HiveRemoteTerminalSessionError: Error { + case missingFrame + case incompleteFrame + case mismatchedWorkspace + case mismatchedSurface + case hostIdentityMismatch +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveRemoteWorkspace.swift new file mode 100644 index 000000000000..cf812c15b3db --- /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 nonisolated struct HiveRemoteWorkspace: Equatable, Sendable, Identifiable { + /// One terminal within the workspace. + public nonisolated 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/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/HiveSyncRuntime.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift new file mode 100644 index 000000000000..7ae85887d0ce --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveSyncRuntime.swift @@ -0,0 +1,99 @@ +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. + /// - 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, + stackAccessTokenForStatusProvider: @escaping @Sendable () async -> String? = { nil } + ) -> HiveSyncRuntime { + let supportedKinds = HiveViewerRoutePolicy( + allowsLoopbackRoutes: allowsLoopbackRoutes + ).supportedRouteKinds + // Tailscale is registered with the viewer-specific factory, which + // remains fail-closed until a cryptographic transport-admission + // handshake is available. DEBUG loopback still uses the shared local + // factory for isolated development sessions. + let networkFactory = CmxNetworkByteTransportFactory(supportedKinds: supportedKinds) + let registrations = supportedKinds.map { kind in + CmxRouteTransportFactoryRegistration( + kind: kind, + factory: kind == .tailscale + ? HiveTailscaleByteTransportFactory() + : 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, + stackAccessTokenForStatusProvider: stackAccessTokenForStatusProvider, + stackAccessTokenForceRefresher: stackAccessTokenForceRefresher + ) + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift new file mode 100644 index 000000000000..df078e80a143 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTailscaleByteTransportFactory.swift @@ -0,0 +1,50 @@ +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 +/// Legacy Tailscale TCP remains fail-closed: a numeric tailnet address is not +/// a cryptographic peer-admission proof, so a Stack bearer is accepted only +/// for a future `.transportAdmission` handshake. DEBUG loopback uses the +/// shared local factory instead. +public struct HiveTailscaleByteTransportFactory: CmxByteTransportFactory { + /// Creates the factory. + public init() {} + + /// Rejects route-only calls because they carry no admission request. + /// - Parameter route: The unadmitted route. + /// - Throws: `CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable`. + public func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + throw CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable + } + + /// Builds a TCP transport for the explicit transport-admission handshake. + /// - Parameter request: The route and its required authorization mode. + /// - Throws: `CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable` + /// when the route is not a tailnet-classified tailscale host. + public func makeTransport(for request: CmxByteTransportRequest) throws -> any CmxByteTransport { + guard request.authorizationMode == .transportAdmission else { + throw CmxNetworkByteTransportError.unsupportedAuthorizationMode( + request.authorizationMode + ) + } + return 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/Sources/CmuxHive/HiveTerminalGridModel.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift new file mode 100644 index 000000000000..b0a73b03b7c0 --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalGridModel.swift @@ -0,0 +1,177 @@ +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 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. + public var column: Int + /// The resolved style for the run. + public var style: MobileTerminalRenderGridFrame.Style + /// The run's text. + public var text: String + /// 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, + totalCellWidth: Int? = nil + ) { + self.column = column + self.style = style + self.text = text + 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. + 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.. 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 } + ) + 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, 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].removeAll(keepingCapacity: true) + } + } + 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, + totalCellWidth: max(span.gridCellWidth, 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 + return true + } + + /// 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.totalCellWidth + } + 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 + } + + 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/CmuxHive/Sources/CmuxHive/HiveTerminalReplayCoordinator.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalReplayCoordinator.swift new file mode 100644 index 000000000000..6d536731d1cd --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveTerminalReplayCoordinator.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Owns one replay request shared by attach and refresh, independently of either waiter. +@MainActor +final class HiveTerminalReplayCoordinator { + private struct Request { + let id: UUID + let task: Task + } + + private var request: Request? + var isRunning: Bool { request != nil } + + /// A new subscription waits for an older replay, then obtains a snapshot + /// requested after that subscription existed so intervening deltas cannot vanish. + func run( + afterCurrent: Bool, + operation: @escaping @MainActor () async throws -> Void + ) async throws { + try Task.checkCancellation() + if afterCurrent, let current = request { + // The previous subscription's failure is not this subscription's + // failure. Its fresh request still gets an independent attempt. + _ = await current.task.result + try Task.checkCancellation() + } + let active: Request + if let current = request { + active = current + } else { + let id = UUID() + let task = Task { [weak self] in + defer { self?.finish(id: id) } + try Task.checkCancellation() + try await operation() + try Task.checkCancellation() + } + active = Request(id: id, task: task) + request = active + } + try await active.task.value + try Task.checkCancellation() + } + + func cancel() { + let current = request + request = nil + current?.task.cancel() + } + + private func finish(id: UUID) { + guard request?.id == id else { return } + request = nil + } +} diff --git a/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveViewerRoutePolicy.swift b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveViewerRoutePolicy.swift new file mode 100644 index 000000000000..e815234a50ae --- /dev/null +++ b/Packages/macOS/CmuxHive/Sources/CmuxHive/HiveViewerRoutePolicy.swift @@ -0,0 +1,32 @@ +public import CMUXMobileCore + +/// Selects the route kinds shared by pairing, row presentation, and dialing. +public struct HiveViewerRoutePolicy: Equatable, Sendable { + /// Whether isolated development sessions may dial this machine's loopback. + public let allowsLoopbackRoutes: Bool + + /// Creates a viewer route policy. + /// - Parameter allowsLoopbackRoutes: Enable loopback only for development sessions. + public init(allowsLoopbackRoutes: Bool) { + self.allowsLoopbackRoutes = allowsLoopbackRoutes + } + + /// The ordered route kinds supported by the viewer's network runtime. + public var supportedRouteKinds: [CmxAttachTransportKind] { + allowsLoopbackRoutes ? [.debugLoopback, .tailscale] : [.tailscale] + } + + /// Whether a route can be selected under this runtime's policy. + /// - Parameter route: An advertised or persisted attach route. + /// - Returns: Whether its transport kind is supported; this is not peer admission. + public func supports(_ route: CmxAttachRoute) -> Bool { + route.kind == .tailscale || (allowsLoopbackRoutes && route.kind == .debugLoopback) + } + + /// Retains only routes that the corresponding viewer runtime can dial. + /// - Parameter routes: Advertised or persisted routes in preference order. + /// - Returns: Supported routes without changing their relative order. + public func supportedRoutes(_ routes: [CmxAttachRoute]) -> [CmxAttachRoute] { + routes.filter { supports($0) } + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryPairByCodeTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryPairByCodeTests.swift new file mode 100644 index 000000000000..84439825e4e1 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryPairByCodeTests.swift @@ -0,0 +1,109 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxHive + +/// 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 let fixture = HiveDirectoryTestFixture() + 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 fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.tailscaleRoute() + let device = codedDevice(deviceId: "coded-mac", code: "042117", route: route) + let directory = fixture.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 fixture.makeTempStore() + defer { cleanup() } + let live = codedDevice(deviceId: "coded-mac", code: "042117", route: try fixture.tailscaleRoute()) + let expired = codedDevice( + deviceId: "expired-mac", + code: "555555", + expiry: Self.pastExpiry, + route: try fixture.tailscaleRoute(host: "100.64.0.10") + ) + let directory = fixture.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 fixture.makeTempStore() + defer { cleanup() } + let first = codedDevice(deviceId: "mac-a", code: "042117", route: try fixture.tailscaleRoute()) + let second = codedDevice( + deviceId: "mac-b", + code: "042117", + route: try fixture.tailscaleRoute(host: "100.64.0.11") + ) + let directory = fixture.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 fixture.makeTempStore() + defer { cleanup() } + let own = codedDevice(deviceId: "self-device", code: "042117", route: try fixture.tailscaleRoute()) + let directory = fixture.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/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift new file mode 100644 index 000000000000..86ea59417df0 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveComputerDirectoryTests.swift @@ -0,0 +1,434 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxHive + +private actor ScopeBox { + var scope: HiveAccountScope + + init(_ scope: HiveAccountScope) { + self.scope = scope + } + + func read() -> HiveAccountScope { + scope + } + + func set(_ scope: HiveAccountScope) { + self.scope = scope + } +} + +@Suite struct HiveComputerDirectoryTests { + private let fixture = HiveDirectoryTestFixture() + @MainActor + @Test func refreshMergesRegistryAndPairedRows() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.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 = fixture.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 scopedUpdatesFollowOnlyTheirDeviceAndSignalRemoval() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let target = RegistryDevice( + deviceId: "target-mac", + platform: "mac", + displayName: "Target", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [] + ) + let other = RegistryDevice( + deviceId: "other-mac", + platform: "mac", + displayName: "Other", + lastSeenAt: Date(timeIntervalSince1970: 800), + instances: [] + ) + let replacement = RegistryDevice( + deviceId: "other-mac", + platform: "mac", + displayName: "Other renamed", + lastSeenAt: Date(timeIntervalSince1970: 1_000), + instances: [] + ) + let directory = HiveComputerDirectory( + registry: SequencedRegistry([ + .ok([target, other]), + .ok([replacement]), + ]), + 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 directory.refresh() + var targetIterator = directory.updates(for: "target-mac").makeAsyncIterator() + var otherIterator = directory.updates(for: "other-mac").makeAsyncIterator() + let initialTargetEvent = try #require(await targetIterator.next()) + let initialTarget = try #require(initialTargetEvent) + let initialOtherEvent = try #require(await otherIterator.next()) + let initialOther = try #require(initialOtherEvent) + #expect(initialTarget.deviceID == "target-mac") + #expect(initialOther.deviceID == "other-mac") + + await directory.refresh() + let removedTarget = try #require(await targetIterator.next()) + #expect(removedTarget == nil) + let updatedOtherEvent = try #require(await otherIterator.next()) + let updatedOther = try #require(updatedOtherEvent) + #expect(updatedOther.displayName == "Other renamed") + } + + @MainActor + @Test func pairFromRegistryPersistsBestRoutes() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.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 = fixture.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 fixture.makeTempStore() + defer { cleanup() } + let ownDevice = RegistryDevice( + deviceId: "self-device", + platform: "mac", + displayName: "This Mac", + lastSeenAt: Date(timeIntervalSince1970: 950), + instances: [ + RegistryAppInstance( + tag: "stable", + routes: [try fixture.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 = fixture.makeDirectory(registry: .ok([ownDevice, routeless, phone]), store: store) + await directory.refresh() + + // 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: "user-1", teamID: "team-1") + #expect(persisted.isEmpty) + } + + @MainActor + @Test func unpairRemovesOnlyTheLocalRecord() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.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 = fixture.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: "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" }) + #expect(row.isPaired == false) + } + + @MainActor + @Test func unpairAfterSignOutDoesNotDeleteScopedRecords() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.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: HiveDirectoryTestFixture.Registry(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() + var scopedIterator = directory.updates(for: "registry-mac").makeAsyncIterator() + let initialScopedEvent = try #require(await scopedIterator.next()) + _ = try #require(initialScopedEvent) + #expect(await directory.pair(deviceID: "registry-mac") == .paired(deviceID: "registry-mac")) + + await scopeBox.set(HiveAccountScope(stackUserID: nil, teamID: nil)) + directory.clearForSignOut() + let removedScopedEvent = try #require(await scopedIterator.next()) + #expect(removedScopedEvent == nil) + #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 fixture.makeTempStore() + defer { cleanup() } + let device = RegistryDevice( + deviceId: "registry-mac", + platform: "mac", + displayName: "Studio", + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [] + ) + let directory = fixture.makeDirectory(registry: .ok([device]), store: store) + await directory.refresh() + #expect(directory.computers.count == 1) + + 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 + @Test func presenceSnapshotMarksDevicesOnline() throws { + let route = try fixture.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 = HiveComputerRowBuilder(ownDeviceID: "self-device").makeRows( + registry: [registryDevice, offlineDevice], + paired: [], + presence: map + ) + 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 pairFromV2TailscaleLinkRequiresRegistryIdentity() async throws { + let (store, cleanup) = try fixture.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, routeDisclosureMode: .legacyPrivateNetworkCompatibility)) + let directory = fixture.makeDirectory(registry: .ok([]), store: store) + + let outcome = await directory.pair(link: link) + #expect(outcome == .unsupportedManualRoute) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.isEmpty) + #expect(directory.computers.isEmpty) + } + + @MainActor + @Test func bestPairingRoutesPrefersOnlineThenFreshest() throws { + let routeA = try fixture.tailscaleRoute(host: "100.64.0.1") + let routeB = try fixture.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/HiveDirectoryScopeTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDirectoryScopeTests.swift new file mode 100644 index 000000000000..a2c00ac684a8 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDirectoryScopeTests.swift @@ -0,0 +1,55 @@ +import CmuxMobileShell +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveDirectoryScopeTests { + @Test(.timeLimit(.minutes(1))) + func scopeReadCannotRevalidateAnOperationAfterSignOut() async throws { + let scope = HiveAccountScope(stackUserID: "user-1", teamID: "team-1") + let gate = HiveScopeReadGate(scope: scope) + let (store, cleanup) = try HiveDirectoryTestFixture().makeTempStore() + defer { cleanup() } + let directory = HiveComputerDirectory( + registry: HiveDirectoryTestFixture.Registry(outcome: .ok([])), + pairedStore: store, presence: nil, ownDeviceID: "self-device", + scopeProvider: { await gate.read() }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + presenceRetryDelay: { _ in } + ) + await directory.refresh() + let generation = directory.scopeGeneration + await gate.holdNextRead() + let validation = Task { await directory.isCurrentScope(scope, generation: generation) } + var started = gate.started.stream.makeAsyncIterator() + _ = try #require(await started.next()) + + directory.clearForSignOut() + await gate.changeScope(to: HiveAccountScope(stackUserID: nil, teamID: nil)) + await gate.finishCapturedRead() + + #expect(await validation.value == false) + } + + @Test func obsoletePairingReloadCannotResurrectSignedOutRows() async throws { + let fixture = HiveDirectoryTestFixture() + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + try await store.upsert( + macDeviceID: "private-mac", displayName: "Private Mac", + routes: [try fixture.tailscaleRoute()], instanceTag: nil, markActive: false, + stackUserID: "user-1", teamID: "team-1", now: Date() + ) + let directory = fixture.makeDirectory(registry: .ok([]), store: store) + await directory.refresh() + try #require(directory.computers.count == 1) + directory.clearForSignOut() + + await directory.reloadPairedRecords(scope: HiveAccountScope(stackUserID: "user-1", teamID: "team-1")) + // Presence invalidation uses this same projection after a stale operation returns. + directory.rebuild() + + #expect(directory.computers.isEmpty) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDirectoryTestFixture.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDirectoryTestFixture.swift new file mode 100644 index 000000000000..805acfba07c4 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDirectoryTestFixture.swift @@ -0,0 +1,62 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxHive + +/// Creates isolated stores and deterministic directories shared by the directory suites. +struct HiveDirectoryTestFixture { + /// Scripted registry double: returns a fixed outcome per call. + struct Registry: DeviceRegistryRefreshing { + var outcome: DeviceRegistryListOutcome + + func freshRoutes(forMacDeviceID macDeviceID: String, instanceTag: String?) async -> [CmxAttachRoute]? { + nil + } + + func listDevices() async -> DeviceRegistryListOutcome { + outcome + } + } + + @MainActor + 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) }) + } + + 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 + func makeDirectory( + registry: DeviceRegistryListOutcome, + store: MobilePairedMacStore, + ownDeviceID: String = "self-device", + stackUserID: String? = "user-1", + teamID: String? = "team-1" + ) -> HiveComputerDirectory { + HiveComputerDirectory( + registry: Registry(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 } + ) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDisconnectAdmissionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDisconnectAdmissionTests.swift new file mode 100644 index 000000000000..826935dc7b2d --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveDisconnectAdmissionTests.swift @@ -0,0 +1,44 @@ +import CmuxMobileRPC +import Testing +@testable import CmuxHive + +@MainActor +struct HiveDisconnectAdmissionTests { + @Test(.timeLimit(.minutes(1))) + func disconnectRevokesTerminalCreationBeforeAwaitingCancelledWork() async throws { + let gate = HiveSessionTeardownGate() + let fixture = try HiveSessionRaceFixture(retryDelay: { _ in await gate.wait() }) + try await fixture.connect() + let retiringClient = try #require(fixture.session.client) + await fixture.transport.killConnection() + var started = gate.started.stream.makeAsyncIterator() + _ = try #require(await started.next()) + + let disconnect = Task { await fixture.session.disconnect() } + var cancelled = gate.cancelled.stream.makeAsyncIterator() + _ = try #require(await cancelled.next()) + #expect(fixture.session.client == nil) + #expect(fixture.session.renderGridRouter == nil) + #expect(fixture.session.makeTerminalSession( + workspaceID: "ws-1", terminalID: "term-1", retryDelay: { _ in } + ) == nil) + #expect(!fixture.session.reconnectIfNeeded()) + let connectionsBeforeRetiredRequest = await fixture.transport.connectCount + let request = try MobileCoreRPCClient.requestData(method: "mobile.workspace.list") + // Retirement forbids another dial, not completion on an installed + // transport before its asynchronous teardown has run. + do { _ = try await retiringClient.sendRequest(request) } + catch MobileShellConnectionError.connectionClosed { } + #expect(await fixture.transport.connectCount == connectionsBeforeRetiredRequest) + + await gate.release() + await disconnect.value + #expect(fixture.session.phase == .idle) + #expect(fixture.session.reconnectIfNeeded()) + try await waitUntil { fixture.session.phase == .connected } + #expect(fixture.session.makeTerminalSession( + workspaceID: "ws-1", terminalID: "term-1", retryDelay: { _ in } + ) != nil) + await fixture.session.disconnect() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveGatedRegistry.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveGatedRegistry.swift new file mode 100644 index 000000000000..021de00225d5 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveGatedRegistry.swift @@ -0,0 +1,35 @@ +import CMUXMobileCore +import CmuxMobileShell + +/// Holds the first registry response while a second account requests a refresh. +actor HiveGatedRegistry: DeviceRegistryRefreshing { + let started = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + private var release: CheckedContinuation? + private var outcomes: [DeviceRegistryListOutcome] + private(set) var calls = 0 + + init(_ outcomes: [DeviceRegistryListOutcome]) { + self.outcomes = outcomes + } + + func freshRoutes(forMacDeviceID macDeviceID: String, instanceTag: String?) async -> [CmxAttachRoute]? { + nil + } + + func listDevices() async -> DeviceRegistryListOutcome { + calls += 1 + let outcome = outcomes.isEmpty ? .transientFailure : outcomes.removeFirst() + if calls == 1 { + await withCheckedContinuation { continuation in + release = continuation + started.continuation.yield(()) + } + } + return outcome + } + + func releaseFirstResponse() { + release?.resume() + release = nil + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveIdentityAndRoutePolicyTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveIdentityAndRoutePolicyTests.swift new file mode 100644 index 000000000000..e75fa2e670dc --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveIdentityAndRoutePolicyTests.swift @@ -0,0 +1,146 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct HiveIdentityAndRoutePolicyTests { + private let fixture = HiveDirectoryTestFixture() + private let upperID = "ABCD1234-AAAA-BBBB-CCCC-1234567890AB" + + @Test func uuidCasingMergesPairingsAndSurvivesIncrementalPresence() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.tailscaleRoute() + try await store.upsert( + macDeviceID: upperID.lowercased(), displayName: "Paired", + routes: [route], instanceTag: "stable", markActive: false, + stackUserID: "user-1", teamID: "team-1", now: Date(timeIntervalSince1970: 950) + ) + let feed = HivePresenceTestFeed() + let directory = makeDirectory( + devices: [device(id: upperID, routes: [route])], store: store, presence: feed + ) + defer { directory.clearForSignOut(); feed.events.continuation.finish() } + await directory.refresh() + #expect(directory.computers.count == 1) + #expect(directory.computers.first?.deviceID == upperID.lowercased()) + #expect(directory.computers.first?.isPaired == true) + var rows = directory.updates(for: upperID).makeAsyncIterator() + _ = await rows.next() + + let liveRoute = try fixture.tailscaleRoute(host: "100.64.0.20") + feed.events.continuation.yield(.routes(PresenceInstance( + deviceId: upperID.lowercased(), tag: "stable", platform: "mac", + online: true, lastSeenAt: 1_100_000, routes: [liveRoute] + ))) + let updated = try #require(await rows.next()) + #expect(updated?.deviceID == upperID.lowercased()) + #expect(updated?.isPaired == true) + #expect(updated?.platform == "mac") + #expect(updated?.instances.first?.routes.first == liveRoute) + #expect(directory.computers.count == 1) + #expect(await directory.pair(deviceID: upperID) == .paired(deviceID: upperID.lowercased())) + #expect(await directory.unpair(deviceID: upperID)) + #expect(directory.computers.count == 1) + #expect(directory.computers.first?.isPaired == false) + } + + @Test func opaqueDeviceIDsKeepTheirCaseAndDoNotShareScopedUpdates() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let route = try fixture.tailscaleRoute() + let registry = HiveDirectoryTestFixture.Registry(outcome: .ok([ + device(id: "Opaque-Host", routes: [route]), device(id: "opaque-host", routes: [route]) + ])) + let feed = HivePresenceTestFeed() + defer { feed.events.continuation.finish() } + let directory = HiveComputerDirectory( + registry: registry, pairedStore: store, presence: feed, ownDeviceID: "Opaque-Host", + scopeProvider: { HiveAccountScope(stackUserID: "user-1", teamID: "team-1") }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), presenceRetryDelay: { _ in } + ) + await directory.refresh() + #expect(directory.computers.count == 2) + #expect(directory.computers.filter(\.isThisComputer).map(\.deviceID) == ["Opaque-Host"]) + var lowerRows = directory.updates(for: "opaque-host").makeAsyncIterator() + let row = try #require(await lowerRows.next()) + #expect(row?.deviceID == "opaque-host") + feed.events.continuation.yield(.routes(PresenceInstance( + deviceId: "opaque-host", tag: "stable", platform: "mac", + online: true, lastSeenAt: 1_100_000, routes: [route] + ))) + let updated = try #require(await lowerRows.next()) + #expect(updated?.deviceID == "opaque-host") + #expect(updated?.isThisComputer == false) + directory.clearForSignOut() + } + + @Test(arguments: [false, true]) + func rowAndCodePairingRespectLoopbackPolicy(allowsLoopback: Bool) async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let loopback = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + let directory = makeDirectory( + devices: [device(id: "loopback-host", routes: [loopback])], + store: store, allowsLoopback: allowsLoopback + ) + await directory.refresh() + #expect(directory.computers.first?.hasViewerSupportedRoute == allowsLoopback) + let expected: HivePairOutcome = allowsLoopback ? .paired(deviceID: "loopback-host") : .noRoutes + #expect(await directory.pair(deviceID: "loopback-host") == expected) + #expect(await directory.pair(code: "042117") == expected) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.isEmpty == !allowsLoopback) + } + + @Test func releaseSelectsAnOlderSupportedInstanceBeforeAFreshLoopbackInstance() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let tailscale = try fixture.tailscaleRoute() + let loopback = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + var host = device(id: "mixed-host", routes: [tailscale]) + host.instances.append(RegistryAppInstance( + tag: "dev", routes: [loopback], lastSeenAt: Date(timeIntervalSince1970: 999) + )) + let directory = makeDirectory(devices: [host], store: store) + await directory.refresh() + #expect(directory.computers.first?.bestPairingRoutes?.routes == [tailscale]) + #expect(directory.computers.first?.bestPairingRoutes?.instanceTag == "stable") + #expect(await directory.pair(deviceID: "mixed-host") == .paired(deviceID: "mixed-host")) + let persisted = try await store.loadAll(stackUserID: "user-1", teamID: "team-1") + #expect(persisted.first?.routes == [tailscale]) + } + + private func device(id: String, routes: [CmxAttachRoute]) -> RegistryDevice { + RegistryDevice( + deviceId: id, platform: "mac", displayName: id, + lastSeenAt: Date(timeIntervalSince1970: 900), + instances: [RegistryAppInstance( + tag: "stable", routes: routes, lastSeenAt: Date(timeIntervalSince1970: 900), + labels: [CmxPairingCode.codeLabelKey: "042117", CmxPairingCode.expiresAtLabelKey: "1970-01-01T00:33:20Z"] + )] + ) + } + + private func makeDirectory( + devices: [RegistryDevice], store: MobilePairedMacStore, + presence: (any PresenceSubscribing)? = nil, allowsLoopback: Bool = false + ) -> HiveComputerDirectory { + HiveComputerDirectory( + registry: HiveDirectoryTestFixture.Registry(outcome: .ok(devices)), + pairedStore: store, presence: presence, ownDeviceID: "self-device", + scopeProvider: { HiveAccountScope(stackUserID: "user-1", teamID: "team-1") }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: allowsLoopback), + now: { Date(timeIntervalSince1970: 1_000) }, presenceRetryDelay: { _ in } + ) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveMirrorRebindCoordinatorTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveMirrorRebindCoordinatorTests.swift new file mode 100644 index 000000000000..491171979aae --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveMirrorRebindCoordinatorTests.swift @@ -0,0 +1,59 @@ +import Testing +@testable import CmuxHive + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct HiveMirrorRebindCoordinatorTests { + @Test func tearingDownTheRouteObserverDoesNotCancelItsReplacement() async throws { + let coordinator = HiveMirrorRebindCoordinator() + let rebound = AsyncStream.makeStream() + defer { rebound.continuation.finish(); coordinator.cancelAll() } + var obsoleteObserver: Task? + obsoleteObserver = Task { + let replacement = coordinator.rebind(key: "device/window") { + obsoleteObserver?.cancel() + rebound.continuation.yield(!Task.isCancelled) + } + await replacement.value + } + var results = rebound.stream.makeAsyncIterator() + #expect(try #require(await results.next())) + #expect(obsoleteObserver?.isCancelled == true) + await obsoleteObserver?.value + } + + @Test func olderCompletionCannotDropTheSuccessorsCancellationHandle() async throws { + let coordinator = HiveMirrorRebindCoordinator() + let firstStarted = AsyncStream.makeStream() + let secondStarted = AsyncStream.makeStream() + let firstGate = AsyncStream.makeStream() + let secondGate = AsyncStream.makeStream() + let cancellation = AsyncStream.makeStream() + defer { + coordinator.cancelAll() + firstGate.continuation.finish(); secondGate.continuation.finish() + firstStarted.continuation.finish(); secondStarted.continuation.finish() + cancellation.continuation.finish() + } + let first = coordinator.rebind(key: "device/window") { + firstStarted.continuation.yield(()) + for await _ in firstGate.stream { break } + } + var firstReady = firstStarted.stream.makeAsyncIterator() + _ = try #require(await firstReady.next()) + let second = coordinator.rebind(key: "device/window") { + secondStarted.continuation.yield(()) + for await _ in secondGate.stream { break } + cancellation.continuation.yield(Task.isCancelled) + } + await first.value + var secondReady = secondStarted.stream.makeAsyncIterator() + _ = try #require(await secondReady.next()) + coordinator.cancelAll { $0 == "device/window" } + // Finish even if a broken owner lost its handle, so the test fails without hanging. + secondGate.continuation.finish() + await second.value + var results = cancellation.stream.makeAsyncIterator() + #expect(try #require(await results.next())) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveMirrorWorkspaceReadinessTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveMirrorWorkspaceReadinessTests.swift new file mode 100644 index 000000000000..e8e7cf19b18c --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveMirrorWorkspaceReadinessTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +@Suite(.timeLimit(.minutes(1))) +struct HiveMirrorWorkspaceReadinessTests { + @Test(arguments: [false, true]) + func reconciledWorkspaceArrivesBeforeOrDuringWait(alreadyReady: Bool) async throws { + let readiness = HiveMirrorWorkspaceReadiness() + let deadline = AsyncStream.makeStream() + let waiting = AsyncStream.makeStream() + defer { readiness.finish(); deadline.continuation.finish(); waiting.continuation.finish() } + let id = UUID() + readiness.publish(UUID()) + readiness.publish(nil) + if alreadyReady { readiness.publish(id) } + let task = Task { + await readiness.wait { + waiting.continuation.yield(()) + for await _ in deadline.stream { break } + } + } + var started = waiting.stream.makeAsyncIterator() + _ = try #require(await started.next()) + if !alreadyReady { readiness.publish(id) } + #expect(await task.value == id) + } + + @Test func teardownEndsTheWaitWithoutRequiringADeadline() async { + let readiness = HiveMirrorWorkspaceReadiness() + let deadline = AsyncStream.makeStream() + defer { deadline.continuation.finish() } + readiness.finish() + #expect(await readiness.wait { for await _ in deadline.stream { break } } == nil) + } + + @Test func deadlineEndsAnEmptyAttachment() async { + let readiness = HiveMirrorWorkspaceReadiness() + defer { readiness.finish() } + #expect(await readiness.wait(timeout: {}) == nil) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingLinkDecoderTests.swift new file mode 100644 index 000000000000..dba050766fd5 --- /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, routeDisclosureMode: .legacyPrivateNetworkCompatibility)) + } + + @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, routeDisclosureMode: .legacyPrivateNetworkCompatibility) + 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/CmuxHive/Tests/CmuxHiveTests/HivePairingScopeTransitionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingScopeTransitionTests.swift new file mode 100644 index 000000000000..2d46a1497cc5 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePairingScopeTransitionTests.swift @@ -0,0 +1,126 @@ +import CMUXMobileCore +import CmuxMobilePairedMac +import CmuxMobileShell +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HivePairingScopeTransitionTests { + private let fixture = HiveDirectoryTestFixture() + + private func legacyLink(deviceID: String) throws -> String { + let ticket = try CmxAttachTicket( + workspaceID: "", terminalID: nil, macDeviceID: deviceID, + macDisplayName: "Test Mac", macUserID: "user-1", + macPairingCompatibilityVersion: 0, + routes: [try fixture.tailscaleRoute()], expiresAt: nil + ) + let data = try CmxAttachTicketCompactCoder().encode( + ticket, routeDisclosureMode: .legacyPrivateNetworkCompatibility + ) + let payload = data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + return "\(CmxPairingURLScheme.development)://attach?payload=\(payload)" + } + + @Test func validLinkPairsAfterTeamSwitchRefreshesItsScope() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let scope = HiveScopeReadGate(scope: HiveAccountScope(stackUserID: "user-1", teamID: "team-1")) + let directory = HiveComputerDirectory( + registry: HiveDirectoryTestFixture.Registry(outcome: .ok([])), + pairedStore: store, presence: nil, ownDeviceID: "self-device", + scopeProvider: { await scope.read() }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + presenceRetryDelay: { _ in } + ) + await directory.refresh() + await scope.changeScope(to: HiveAccountScope(stackUserID: "user-1", teamID: "team-2")) + + #expect(await directory.pair(link: try legacyLink(deviceID: "mac-b")) == .paired(deviceID: "mac-b")) + #expect(try await store.loadAll(stackUserID: "user-1", teamID: "team-1").isEmpty) + let newTeam = try await store.loadAll(stackUserID: "user-1", teamID: "team-2") + #expect(newTeam.map(\.macDeviceID) == ["mac-b"]) + } + + @Test(arguments: [ + ("self-device", "self-device"), + ("FCAB639B-4765-4524-B0F9-72329620D51C", "fcab639b-4765-4524-b0f9-72329620d51c") + ]) + func legacyLinkCannotPairThisMac(deviceID: String, ownDeviceID: String) async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let directory = fixture.makeDirectory( + registry: .ok([]), store: store, ownDeviceID: ownDeviceID + ) + + #expect(await directory.pair(link: try legacyLink(deviceID: deviceID)) == .loopbackRejected) + #expect(try await store.loadAll(stackUserID: "user-1", teamID: "team-1").isEmpty) + } + + @Test(.timeLimit(.minutes(1))) + func coalescedRefreshLoadsTheNewAccountBeforeReturning() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let device = RegistryDevice( + deviceId: "new-team-mac", platform: "mac", displayName: "New Team Mac", + lastSeenAt: Date(), instances: [] + ) + let registry = HiveGatedRegistry([.ok([]), .ok([device])]) + let scope = HiveScopeReadGate(scope: HiveAccountScope(stackUserID: "user-1", teamID: "team-1")) + let directory = HiveComputerDirectory( + registry: registry, pairedStore: store, presence: nil, ownDeviceID: "self-device", + scopeProvider: { await scope.read() }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + presenceRetryDelay: { _ in } + ) + let oldRefresh = Task { await directory.refresh() } + var firstStarted = registry.started.stream.makeAsyncIterator() + _ = try #require(await firstStarted.next()) + await scope.changeScope(to: HiveAccountScope(stackUserID: "user-1", teamID: "team-2")) + let waiting = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let newRefresh = Task { + waiting.continuation.yield(()) + await directory.refresh() + } + var waiterStarted = waiting.stream.makeAsyncIterator() + _ = try #require(await waiterStarted.next()) + await registry.releaseFirstResponse() + await oldRefresh.value + await newRefresh.value + + #expect(await registry.calls == 2) + #expect(directory.computers.map(\.deviceID) == ["new-team-mac"]) + } + + @Test(.timeLimit(.minutes(1))) + func anOldRefreshCannotUndoExplicitSignOut() async throws { + let (store, cleanup) = try fixture.makeTempStore() + defer { cleanup() } + let privateMac = RegistryDevice( + deviceId: "private-mac", platform: "mac", displayName: "Private Mac", + lastSeenAt: Date(), instances: [] + ) + let registry = HiveGatedRegistry([.ok([privateMac]), .ok([privateMac])]) + let directory = HiveComputerDirectory( + registry: registry, pairedStore: store, presence: nil, ownDeviceID: "self-device", + scopeProvider: { HiveAccountScope(stackUserID: "user-1", teamID: "team-1") }, + linkDecoder: HivePairingLinkDecoder(allowsLoopbackRoutes: false), + presenceRetryDelay: { _ in } + ) + let pending = Task { await directory.refresh() } + var started = registry.started.stream.makeAsyncIterator() + _ = try #require(await started.next()) + directory.clearForSignOut() + await registry.releaseFirstResponse() + await pending.value + + #expect(directory.computers.isEmpty) + #expect(directory.loadedScope == HiveAccountScope(stackUserID: nil, teamID: nil)) + #expect(await registry.calls == 1) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePresenceTestFeed.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePresenceTestFeed.swift new file mode 100644 index 000000000000..7f2e66d794f0 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HivePresenceTestFeed.swift @@ -0,0 +1,10 @@ +import CmuxMobileShell + +/// Supplies buffered presence events without a socket or scheduling sleeps. +struct HivePresenceTestFeed: PresenceSubscribing { + let events = AsyncThrowingStream.makeStream() + + func subscribe() async throws -> AsyncThrowingStream { + events.stream + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift new file mode 100644 index 000000000000..c5f7ac2126a5 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRemoteSessionTests.swift @@ -0,0 +1,445 @@ +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 enum RetryTestError: Error { + case initialDialFailure +} + +/// Fails the first route admission, then hands out the scripted transport so +/// the session's explicit retry path can be exercised without a real socket. +private final class ThrowingOnceTransportFactory: @unchecked Sendable, CmxByteTransportFactory { + private let lock = NSLock() + private var shouldThrow = true + private let transport: ScriptedHostTransport + + init(transport: ScriptedHostTransport) { + self.transport = transport + } + + func makeTransport(for route: CmxAttachRoute) throws -> any CmxByteTransport { + try lock.withLock { + if shouldThrow { + shouldThrow = false + throw RetryTestError.initialDialFailure + } + return transport + } + } +} + +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( + surfaceID: String = "term-1", + text: String, + stateSeq: UInt64 +) throws -> String { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: surfaceID, + 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 { + private static func legacyTailscaleEvidence() throws -> CmxLegacyTailscaleAuthorizationEvidence { + try CmxLegacyTailscaleAuthorizationEvidence( + macDeviceID: "mac-b", + host: "100.64.0.9", + port: 8000 + ) + } + + @MainActor + @Test func workspaceEventDuringInitialRefreshIsNotLost() async throws { + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return workspaceListResult() + case "mobile.events.subscribe": return ["stream_id": "s"] + default: return [:] + } + } + try await transport.pushEventAfterResponse( + to: "mobile.workspace.list", occurrence: 2, topic: "workspace.updated" + ) + let session = HiveRemoteMacSession( + runtime: makeRuntime(transport: transport), + macDeviceID: "mac-b", displayName: "Studio", + routes: [try tailscaleRoute()], retryDelay: { _ in }, + legacyTailscaleAuthorizationEvidence: try Self.legacyTailscaleEvidence(), + requiresHostIdentity: false + ) + session.connect() + do { + // The event follows the snapshot response on the same byte stream. + // It must trigger a third list request even before snapshot decoding finishes. + try await transport.waitForMethod("mobile.workspace.list", count: 3) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } + + @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 }, + legacyTailscaleAuthorizationEvidence: try Self.legacyTailscaleEvidence(), + requiresHostIdentity: false + ) + session.connect() + try await transport.waitForMethod("mobile.events.subscribe") + // The event loop refreshes once after subscribing. + try 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: [:]) + try await transport.waitForMethod("mobile.workspace.list", count: 3) + + await session.disconnect() + } + + @MainActor + @Test func reconnectIfNeededRestartsAFailedSession() 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 runtime = HiveSyncRuntime( + supportedRouteKinds: [.tailscale], + transportFactory: ThrowingOnceTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let session = HiveRemoteMacSession( + runtime: runtime, + macDeviceID: "mac-b", + displayName: "Studio", + routes: [try tailscaleRoute()], + retryDelay: { _ in }, + legacyTailscaleAuthorizationEvidence: try Self.legacyTailscaleEvidence(), + requiresHostIdentity: false + ) + + session.connect() + try await waitUntil { + if case .failed = session.phase { return true } + return false + } + + #expect(session.reconnectIfNeeded()) + try await transport.waitForMethod("mobile.events.subscribe") + try await transport.waitForMethod("mobile.workspace.list") + try await waitUntil { session.phase == .connected && session.workspaces.count == 2 } + #expect(!session.reconnectIfNeeded()) + + 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 }, + legacyTailscaleAuthorizationEvidence: try Self.legacyTailscaleEvidence(), + requiresHostIdentity: false + ) + 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: "a") + terminal.send(text: "b") + terminal.send(text: "c") + try await transport.waitForMethod("mobile.terminal.input", count: 3) + let inputs = await transport.sentInputTexts + #expect(inputs == ["a", "b", "c"]) + + terminal.detach() + terminal.send(text: "late") + await Task.yield() + #expect(await transport.sentInputTexts == ["a", "b", "c"]) + 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 }, + legacyTailscaleAuthorizationEvidence: try Self.legacyTailscaleEvidence(), + requiresHostIdentity: false + ) + 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() + try 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() + } + + @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 }, + legacyTailscaleAuthorizationEvidence: try Self.legacyTailscaleEvidence(), + 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() + try 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 +/// 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/HiveReplayAttachmentRaceTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayAttachmentRaceTests.swift new file mode 100644 index 000000000000..7c840f74dd04 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayAttachmentRaceTests.swift @@ -0,0 +1,61 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveReplayAttachmentRaceTests { + @MainActor private final class RetryObservation { var count = 0 } + + @Test(.timeLimit(.minutes(1)), arguments: 0..<10) + func attachingWhileRefreshOwnsReplayKeepsTheSubscription(_ iteration: Int) async throws { + let fixture = try HiveSessionRaceFixture() + try await fixture.connect() + let client = try #require(fixture.session.client) + let subscriptionCount = await fixture.transport.sentMethods.filter { + $0 == "mobile.events.subscribe" + }.count + await fixture.transport.holdResponse(to: "mobile.events.subscribe", occurrence: subscriptionCount + 1) + await fixture.transport.holdResponse(to: "mobile.terminal.replay", occurrence: 1) + let retries = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let retry = RetryObservation() + let terminal = HiveRemoteTerminalSession( + client: client, workspaceID: "ws-1", terminalID: "term-1", + retryDelay: { _ in + await MainActor.run { retry.count += 1 } + for await _ in retries.stream { break } + } + ) + defer { + retries.continuation.finish() + terminal.detach() + } + terminal.attach() + do { + try await fixture.transport.waitForMethod("mobile.events.subscribe", count: subscriptionCount + 1) + terminal.refreshReplay() + try await fixture.transport.waitForMethod("mobile.terminal.replay") + await fixture.transport.releaseResponse(to: "mobile.events.subscribe", occurrence: subscriptionCount + 1) + // An independent RPC allows the released subscription response to + // cross the real client while the replay response remains gated. + let barrier = try MobileCoreRPCClient.requestData(method: "test.barrier") + _ = try await client.sendRequest(barrier) + await fixture.transport.releaseResponse(to: "mobile.terminal.replay", occurrence: 1) + try await waitUntil { terminal.phase == .live || retry.count > 0 } + #expect(retry.count == 0) + #expect(terminal.phase == .live) + if terminal.phase == .live { + terminal.send(text: "after-attach-\(iteration)") + try await fixture.transport.waitForMethod("mobile.terminal.input") + #expect(await fixture.transport.sentInputTexts == ["after-attach-\(iteration)"]) + } + } catch { + terminal.detach() + await fixture.session.disconnect() + throw error + } + terminal.detach() + await fixture.session.disconnect() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayIdentityTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayIdentityTests.swift new file mode 100644 index 000000000000..edda60301f91 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayIdentityTests.swift @@ -0,0 +1,66 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveReplayIdentityTests { + @Test(arguments: ["workspace_id", "surface_id"], [false, true]) + func rejectsReplayWithInvalidEnvelopeIdentity(field: String, missing: Bool) async throws { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: "term-1", stateSeq: 1, columns: 20, rows: 5, full: true, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "wrong terminal")] + ) + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return ["workspaces": []] + case "mobile.events.subscribe": return ["stream_id": "events"] + case "mobile.terminal.replay": + var response: [String: Any] = [ + "workspace_id": "ws-1", "surface_id": "term-1", + "render_grid": (try? frame.jsonObject()) ?? [:], + ] + response[field] = missing ? nil : "another-terminal" + return response + default: return [:] + } + } + let runtime = HiveSyncRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let route = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + let session = HiveRemoteMacSession( + runtime: runtime, macDeviceID: "mac-b", displayName: "Test Mac", + routes: [route], retryDelay: { _ in }, requiresHostIdentity: false + ) + session.connect() + try await waitUntil { session.phase == .connected } + let client = try #require(session.client) + let terminal = HiveRemoteTerminalSession( + client: client, workspaceID: "ws-1", terminalID: "term-1", + retryDelay: { _ in await HiveReconnectBackoff(maximumSeconds: 60).delay(attempt: 6) } + ) + terminal.attach() + do { + try await transport.waitForMethod("mobile.terminal.replay") + try await waitUntil { terminal.phase == .live || terminal.phase == .reattaching } + #expect(terminal.phase == .reattaching) + #expect(!terminal.grid.hasContent) + #expect(terminal.lastFullFrameBytes == nil) + } catch { + terminal.detach() + await session.disconnect() + throw error + } + terminal.detach() + await session.disconnect() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayInputRecoveryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayInputRecoveryTests.swift new file mode 100644 index 000000000000..430ba4ac319a --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveReplayInputRecoveryTests.swift @@ -0,0 +1,131 @@ +import CMUXMobileCore +import CmuxMobileRPC +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveReplayInputRecoveryTests { + @Test(.timeLimit(.minutes(1))) + func successfulReplayDrainsInputQueuedDuringInitialAttach() async throws { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: "term-1", stateSeq: 1, columns: 20, rows: 5, full: true, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "ready")] + ) + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return ["workspaces": []] + case "mobile.events.subscribe": return ["stream_id": "events"] + case "mobile.terminal.replay": + return [ + "workspace_id": "ws-1", "surface_id": "term-1", + "render_grid": (try? frame.jsonObject()) ?? [:], + ] + default: return [:] + } + } + await transport.holdResponse(to: "mobile.terminal.replay", occurrence: 1) + let runtime = HiveSyncRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let route = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + let session = HiveRemoteMacSession( + runtime: runtime, macDeviceID: "mac-b", displayName: "Test Mac", + routes: [route], retryDelay: { _ in }, requiresHostIdentity: false + ) + session.connect() + try await waitUntil { session.phase == .connected } + let client = try #require(session.client) + let terminal = HiveRemoteTerminalSession( + client: client, workspaceID: "ws-1", terminalID: "term-1", + retryDelay: { _ in } + ) + terminal.attach() + try await transport.waitForMethod("mobile.terminal.replay") + try #require(terminal.phase == .attaching) + terminal.send(text: "queued-during-attach") + await transport.releaseResponse(to: "mobile.terminal.replay", occurrence: 1) + try await waitUntil { terminal.phase == .live } + + try await transport.waitForMethod("mobile.terminal.input") + #expect(await transport.sentInputTexts == ["queued-during-attach"]) + terminal.detach() + await session.disconnect() + } + + @Test(.timeLimit(.minutes(1))) + func successfulReplayDrainsInputQueuedDuringRecovery() async throws { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: "term-1", stateSeq: 1, columns: 20, rows: 5, full: true, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "ready")] + ) + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return ["workspaces": []] + case "mobile.events.subscribe": return ["stream_id": "events"] + case "mobile.terminal.replay": + return [ + "workspace_id": "ws-1", "surface_id": "term-1", + "render_grid": (try? frame.jsonObject()) ?? [:], + ] + default: return [:] + } + } + await transport.failResponse(to: "mobile.terminal.replay", occurrence: 2) + let runtime = HiveSyncRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let route = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + let session = HiveRemoteMacSession( + runtime: runtime, macDeviceID: "mac-b", displayName: "Test Mac", + routes: [route], retryDelay: { _ in }, requiresHostIdentity: false + ) + session.connect() + try await waitUntil { session.phase == .connected } + let client = try #require(session.client) + let recoveryStarted = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let recoveryRelease = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + defer { recoveryRelease.continuation.finish() } + let terminal = HiveRemoteTerminalSession( + client: client, workspaceID: "ws-1", terminalID: "term-1", + retryDelay: { _ in + recoveryStarted.continuation.yield(()) + for await _ in recoveryRelease.stream { break } + } + ) + terminal.attach() + do { + try await waitUntil { terminal.phase == .live } + terminal.refreshReplay() + var started = recoveryStarted.stream.makeAsyncIterator() + _ = try #require(await started.next()) + try #require(terminal.phase == .reattaching) + terminal.send(text: "queued-during-recovery") + recoveryRelease.continuation.finish() + try await waitUntil { terminal.phase == .live } + + try await transport.waitForMethod("mobile.terminal.input") + #expect(await transport.sentInputTexts == ["queued-during-recovery"]) + } catch { + terminal.detach() + await session.disconnect() + throw error + } + terminal.detach() + await session.disconnect() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRetryCancellationTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRetryCancellationTests.swift new file mode 100644 index 000000000000..38784fc0b169 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveRetryCancellationTests.swift @@ -0,0 +1,59 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveRetryCancellationTests { + @Test(.timeLimit(.minutes(1))) + func disconnectDuringBackoffDoesNotSendAnotherRequest() async throws { + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return ["workspaces": []] + case "mobile.events.subscribe": return ["stream_id": "events"] + default: return [:] + } + } + let started = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let finished = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let runtime = HiveSyncRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let route = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + let session = HiveRemoteMacSession( + runtime: runtime, macDeviceID: "mac-b", displayName: "Test Mac", + routes: [route], + retryDelay: { _ in + started.continuation.yield(()) + await HiveReconnectBackoff(maximumSeconds: 60).delay(attempt: 6) + finished.continuation.yield(()) + }, + requiresHostIdentity: false + ) + session.connect() + do { + try await transport.waitForMethod("mobile.workspace.list", count: 2) + await transport.killConnection() + var starting = started.stream.makeAsyncIterator() + _ = try #require(await starting.next()) + let requestsBeforeDisconnect = await transport.sentMethods + + await session.disconnect() + + var finishing = finished.stream.makeAsyncIterator() + _ = try #require(await finishing.next()) + #expect(session.phase == .idle) + #expect(await transport.sentMethods == requestsBeforeDisconnect) + } catch { + await session.disconnect() + throw error + } + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveScopeReadGate.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveScopeReadGate.swift new file mode 100644 index 000000000000..9cd08be9d9ae --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveScopeReadGate.swift @@ -0,0 +1,34 @@ +import Foundation +@testable import CmuxHive + +/// Holds one captured account read while the authoritative account changes. +actor HiveScopeReadGate { + private var scope: HiveAccountScope + private var suspendNextRead = false + private var pending: CheckedContinuation? + private var capturedScope: HiveAccountScope? + let started = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + + init(scope: HiveAccountScope) { self.scope = scope } + + func read() async -> HiveAccountScope { + guard suspendNextRead else { return scope } + suspendNextRead = false + capturedScope = scope + return await withCheckedContinuation { continuation in + pending = continuation + started.continuation.yield(()) + } + } + + func holdNextRead() { suspendNextRead = true } + + func changeScope(to scope: HiveAccountScope) { self.scope = scope } + + func finishCapturedRead() { + guard let capturedScope else { return } + pending?.resume(returning: capturedScope) + pending = nil + self.capturedScope = nil + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveSessionRaceFixture.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveSessionRaceFixture.swift new file mode 100644 index 000000000000..7110306dbc05 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveSessionRaceFixture.swift @@ -0,0 +1,50 @@ +import CMUXMobileCore +import Foundation +@testable import CmuxHive + +/// Builds real Hive/RPC sessions over a host whose responses can be released explicitly. +@MainActor +struct HiveSessionRaceFixture { + let transport: ScriptedHostTransport + let session: HiveRemoteMacSession + + init(retryDelay: @escaping @Sendable (Int) async -> Void = { _ in }) throws { + let frame = try MobileTerminalRenderGridFrame( + surfaceID: "term-1", stateSeq: 1, columns: 20, rows: 5, full: true, + rowSpans: [.init(row: 0, column: 0, styleID: 0, text: "ready")] + ) + transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return ["workspaces": []] + case "mobile.events.subscribe": return ["stream_id": "events"] + case "mobile.terminal.replay": + return [ + "workspace_id": "ws-1", "surface_id": "term-1", + "render_grid": (try? frame.jsonObject()) ?? [:], + ] + default: return [:] + } + } + let runtime = HiveSyncRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let route = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + session = HiveRemoteMacSession( + runtime: runtime, macDeviceID: "mac-b", displayName: "Test Mac", + routes: [route], retryDelay: retryDelay, requiresHostIdentity: false + ) + } + + func connect() async throws { + session.connect() + try await waitUntil { session.phase == .connected } + _ = await session.refreshWorkspaces() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveSessionTeardownGate.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveSessionTeardownGate.swift new file mode 100644 index 000000000000..174c5e027d1a --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveSessionTeardownGate.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Keeps cancellation cleanup suspended until the test checks synchronous revocation. +actor HiveSessionTeardownGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + let started = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let cancelled = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + + func wait() async { + guard !isOpen else { return } + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + waiters.append(continuation) + started.continuation.yield(()) + } + } onCancel: { + self.cancelled.continuation.yield(()) + } + } + + func release() { + isOpen = true + let pending = waiters + waiters.removeAll() + for waiter in pending { waiter.resume() } + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift new file mode 100644 index 000000000000..2c09a7a08195 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTailscaleByteTransportFactoryTests.swift @@ -0,0 +1,61 @@ +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)) + } + + private func admissionRequest(kind: CmxAttachTransportKind, host: String) throws -> CmxByteTransportRequest { + CmxByteTransportRequest( + route: try route(kind: kind, host: host), + expectedPeerDeviceID: "mac-b", authorizationMode: .transportAdmission + ) + } + + @Test func buildsTransportsForTailnetHostsOnly() throws { + let factory = HiveTailscaleByteTransportFactory() + #expect(throws: Never.self) { + _ = try factory.makeTransport(for: try admissionRequest(kind: .tailscale, host: "100.65.181.35")) + } + #expect(throws: Never.self) { + _ = try factory.makeTransport(for: try admissionRequest(kind: .tailscale, host: "fd7a:115c:a1e0::f536:b524")) + } + #expect(throws: Never.self) { + _ = try factory.makeTransport(for: try admissionRequest(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 admissionRequest(kind: .tailscale, host: "192.168.86.21")) + } + #expect(throws: (any Error).self) { + _ = try factory.makeTransport(for: try admissionRequest(kind: .debugLoopback, host: "127.0.0.1")) + } + } + + @Test(arguments: ["100.65.181.35", "fd7a:115c:a1e0::f536:b524", "mini.tail1234.ts.net"]) + func routeOnlyCallsCannotBypassAdmission(host: String) throws { + #expect(throws: CmxNetworkByteTransportError.tailscaleAuthorizationUnavailable) { + _ = try HiveTailscaleByteTransportFactory().makeTransport( + for: try route(kind: .tailscale, host: host) + ) + } + } + + @Test func rejectsStackBearerRequestsUntilPeerAdmissionIsProven() 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) + } + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift new file mode 100644 index 000000000000..bb9c202f6482 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalGridModelTests.swift @@ -0,0 +1,208 @@ +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.dropFirst().first?.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 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)) + 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 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: 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) + } + + @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) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalReplayCoordinatorTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalReplayCoordinatorTests.swift new file mode 100644 index 000000000000..dc4d2043dc21 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveTerminalReplayCoordinatorTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveTerminalReplayCoordinatorTests { + private enum Failure: Error { case replay } + + @Test(.timeLimit(.minutes(1))) + func refreshWaitersShareOneRequest() async throws { + let coordinator = HiveTerminalReplayCoordinator() + let gate = HiveSessionTeardownGate() + var requests = 0 + let first = Task { + try await coordinator.run(afterCurrent: false) { + requests += 1 + await gate.wait() + } + } + var started = gate.started.stream.makeAsyncIterator() + _ = try #require(await started.next()) + let joined = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let second = Task { + joined.continuation.yield(()) + try await coordinator.run(afterCurrent: false) { requests += 1 } + } + var joining = joined.stream.makeAsyncIterator() + _ = try #require(await joining.next()) + #expect(requests == 1) + await gate.release() + try await first.value + try await second.value + #expect(requests == 1) + #expect(!coordinator.isRunning) + } + + @Test(.timeLimit(.minutes(1))) + func aNewSubscriptionRequestsAFreshSnapshotEvenIfTheOldRequestFails() async throws { + let coordinator = HiveTerminalReplayCoordinator() + let gate = HiveSessionTeardownGate() + var requestedFreshSnapshot = false + let first = Task { + try await coordinator.run(afterCurrent: false) { + await gate.wait() + throw Failure.replay + } + } + var started = gate.started.stream.makeAsyncIterator() + _ = try #require(await started.next()) + let waiting = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + let second = Task { + waiting.continuation.yield(()) + try await coordinator.run(afterCurrent: true) { requestedFreshSnapshot = true } + } + var listener = waiting.stream.makeAsyncIterator() + _ = try #require(await listener.next()) + #expect(!requestedFreshSnapshot) + await gate.release() + await #expect(throws: Failure.self) { try await first.value } + try await second.value + #expect(requestedFreshSnapshot) + } + + @Test(.timeLimit(.minutes(1))) + func cancelledRequestCannotClearItsSuccessorsOwnership() async throws { + let coordinator = HiveTerminalReplayCoordinator() + let oldGate = HiveSessionTeardownGate() + let newGate = HiveSessionTeardownGate() + let first = Task { + try await coordinator.run(afterCurrent: false) { await oldGate.wait() } + } + var oldStarted = oldGate.started.stream.makeAsyncIterator() + _ = try #require(await oldStarted.next()) + coordinator.cancel() + let second = Task { + try await coordinator.run(afterCurrent: false) { await newGate.wait() } + } + var newStarted = newGate.started.stream.makeAsyncIterator() + _ = try #require(await newStarted.next()) + await oldGate.release() + await #expect(throws: CancellationError.self) { try await first.value } + #expect(coordinator.isRunning) + await newGate.release() + try await second.value + #expect(!coordinator.isRunning) + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveWorkspaceRefreshTeardownTests.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveWorkspaceRefreshTeardownTests.swift new file mode 100644 index 000000000000..a64a23265519 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/HiveWorkspaceRefreshTeardownTests.swift @@ -0,0 +1,54 @@ +import CMUXMobileCore +import Foundation +import Testing +@testable import CmuxHive + +@MainActor +struct HiveWorkspaceRefreshTeardownTests { + @Test(.timeLimit(.minutes(1))) + func disconnectWithPendingRefreshAllowsAWorkingSuccessorRefresh() async throws { + let transport = ScriptedHostTransport { method, _ in + switch method { + case "mobile.workspace.list": return ["workspaces": []] + case "mobile.events.subscribe": return ["stream_id": "events"] + default: return [:] + } + } + let runtime = HiveSyncRuntime( + supportedRouteKinds: [.debugLoopback], + transportFactory: ScriptedHostTransportFactory(transport: transport), + stackAccessTokenProvider: { "test-stack-token" }, + stackAccessTokenForceRefresher: { "test-stack-token" }, + rpcRequestTimeoutNanoseconds: 5_000_000_000 + ) + let route = try CmxAttachRoute( + id: "loopback", kind: .debugLoopback, + endpoint: .hostPort(host: "127.0.0.1", port: 8000) + ) + let session = HiveRemoteMacSession( + runtime: runtime, macDeviceID: "mac-b", displayName: "Test Mac", + routes: [route], retryDelay: { _ in }, requiresHostIdentity: false + ) + session.connect() + do { + try await transport.waitForMethod("mobile.workspace.list", count: 2) + try await waitUntil { session.phase == .connected } + #expect(await session.refreshWorkspaces()) + let before = await transport.sentMethods.filter { $0 == "mobile.workspace.list" }.count + await transport.dropResponse(to: "mobile.workspace.list", occurrence: before + 1) + let oldRefresh = Task { await session.refreshWorkspaces() } + try await transport.waitForMethod("mobile.workspace.list", count: before + 1) + + await session.disconnect() + #expect(await oldRefresh.value == false) + #expect(session.reconnectIfNeeded()) + try await waitUntil { session.phase == .connected } + #expect(await session.refreshWorkspaces()) + #expect(session.phase == .connected) + } catch { + await session.disconnect() + throw error + } + await session.disconnect() + } +} diff --git a/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift new file mode 100644 index 000000000000..6c37eb2a9400 --- /dev/null +++ b/Packages/macOS/CmuxHive/Tests/CmuxHiveTests/ScriptedHostTransport.swift @@ -0,0 +1,210 @@ +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 connectCount = 0 + private(set) var sentMethods: [String] = [] + private(set) var sentInputTexts: [String] = [] + private struct MethodWaiter { + let method: String + let count: Int + let continuation: AsyncStream.Continuation + } + private struct ResponseEvent { + let method: String + let occurrence: Int + let frame: Data + } + enum WaitError: Error { case methodNotReceived(String) } + private var methodWaiters: [UUID: MethodWaiter] = [:] + private var responseEvents: [ResponseEvent] = [] + private var failingResponseOccurrences: [String: Set] = [:] + private var droppedResponseOccurrences: [String: Set] = [:] + private var heldResponseOccurrences: [String: Set] = [:] + private var heldResponses: [String: [Int: Data]] = [:] + + init(handler: @escaping Handler) { + self.handler = handler + } + + func connect() async throws { + connectCount += 1 + 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 occurrence = sentMethods.filter { $0 == method }.count + let result = handler(method, params) + let envelope: [String: Any] + if failingResponseOccurrences[method]?.contains(occurrence) == true { + envelope = [ + "id": id, "ok": false, + "error": ["code": "scripted_failure", "message": "Scripted RPC failure"], + ] + } else { + envelope = ["id": id, "ok": true, "result": result] + } + if droppedResponseOccurrences[method]?.contains(occurrence) != true, + let payload = try? JSONSerialization.data(withJSONObject: envelope), + let framed = try? MobileSyncFrameCodec.encodeFrame(payload) { + if heldResponseOccurrences[method]?.contains(occurrence) == true { + heldResponses[method, default: [:]][occurrence] = framed + } else { + deliver(framed) + } + } + for event in responseEvents where event.method == method && event.occurrence == occurrence { + deliver(event.frame) + } + } + } + + /// Reject one RPC while preserving the byte connection for recovery tests. + func failResponse(to method: String, occurrence: Int) { + failingResponseOccurrences[method, default: []].insert(occurrence) + } + + /// Leave one RPC pending until caller cancellation or disconnect. + func dropResponse(to method: String, occurrence: Int) { + droppedResponseOccurrences[method, default: []].insert(occurrence) + } + + /// Hold a response without blocking independent requests on the same connection. + func holdResponse(to method: String, occurrence: Int) { + heldResponseOccurrences[method, default: []].insert(occurrence) + } + + /// Deliver the captured response after the test has established the competing operation. + func releaseResponse(to method: String, occurrence: Int) { + heldResponseOccurrences[method]?.remove(occurrence) + if let response = heldResponses[method]?.removeValue(forKey: occurrence) { + deliver(response) + } + } + + 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() { + isClosed = true + 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 throws { + guard sentMethods.filter({ $0 == method }).count < count else { return } + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + methodWaiters[id] = MethodWaiter(method: method, count: count, continuation: continuation) + defer { + methodWaiters.removeValue(forKey: id)?.continuation.finish() + } + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { for await _ in stream { return } } + group.addTask { + // A deadline bounds a broken test; it is not used to settle state. + try await ContinuousClock().sleep(for: .seconds(5)) + throw WaitError.methodNotReceived(method) + } + defer { group.cancelAll() } + _ = try await group.next() + } + } + + /// Deliver an event immediately after a chosen response, before client decoding can finish. + func pushEventAfterResponse( + to method: String, + occurrence: Int, + topic: String + ) throws { + let envelope: [String: Any] = [ + "kind": "event", "topic": topic, "payload": [:], + ] + let data = try JSONSerialization.data(withJSONObject: envelope) + responseEvents.append(ResponseEvent( + method: method, + occurrence: occurrence, + frame: try MobileSyncFrameCodec.encodeFrame(data) + )) + } + + private func resolveMethodWaiters(method: String) { + let count = sentMethods.filter { $0 == method }.count + let matching = methodWaiters.filter { $0.value.method == method && $0.value.count <= count } + for (id, waiter) in matching { + methodWaiters.removeValue(forKey: id) + waiter.continuation.yield(()) + waiter.continuation.finish() + } + } + + 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/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/Package.swift b/Packages/macOS/CmuxHiveUI/Package.swift new file mode 100644 index 000000000000..b7972e72cd5b --- /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: "../../Shared/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/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/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveRemoteTerminalPane.swift new file mode 100644 index 000000000000..89a56c31910a --- /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: terminal.phase == .live + ) + 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/HiveTerminalGridMetrics.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetrics.swift new file mode 100644 index 000000000000..145d9f5253a4 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridMetrics.swift @@ -0,0 +1,41 @@ +import AppKit +import SwiftUI + +/// Cell geometry for fitting a remote terminal grid into a Canvas. +struct HiveTerminalGridMetrics: Equatable { + private static let referenceSize: CGFloat = 13 + // Cache only Sendable scalar measurements, not a shared AppKit object. + private static let referenceMetrics: (advance: CGFloat, lineHeight: CGFloat) = { + let font = NSFont.monospacedSystemFont(ofSize: referenceSize, weight: .regular) + return ( + ("0" as NSString).size(withAttributes: [.font: font]).width, + NSLayoutManager().defaultLineHeight(for: font) + ) + }() + + 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 widthLimited = available.width / (CGFloat(columns) * Self.referenceMetrics.advance / Self.referenceSize) + let heightLimited = available.height / (CGFloat(rows) * Self.referenceMetrics.lineHeight / Self.referenceSize) + let size = max(min(widthLimited, heightLimited, 20), 4) + fontSize = size + cellWidth = Self.referenceMetrics.advance * size / Self.referenceSize + lineHeight = Self.referenceMetrics.lineHeight * size / Self.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 new file mode 100644 index 000000000000..590d8cac84f5 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalGridView.swift @@ -0,0 +1,173 @@ +public import CmuxHive +import CMUXMobileCore +import AppKit +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 + @State private var cachedMetrics: HiveTerminalGridMetricsCache? + @State private var availableSize: CGSize = .zero + + /// Creates a grid view over one immutable grid snapshot. + public init(grid: HiveTerminalGridModel) { + self.grid = grid + } + + public var body: some View { + 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 + availableSize = size + rebuildMetricsCache(available: size) + } + .onChange(of: grid.columns) { _, _ in rebuildMetricsCache(available: availableSize) } + .onChange(of: grid.rows) { _, _ in rebuildMetricsCache(available: availableSize) } + } + + private func rebuildMetricsCache(available: CGSize) { + guard available.width > 0, available.height > 0 else { return } + cachedMetrics = HiveTerminalGridMetricsCache( + columns: grid.columns, + rows: grid.rows, + available: available, + metrics: HiveTerminalGridMetrics( + columns: grid.columns, + rows: grid.rows, + available: available + ) + ) + } + + 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) + } + +} diff --git a/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift new file mode 100644 index 000000000000..ab691e4909e6 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveTerminalInputView.swift @@ -0,0 +1,254 @@ +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 + nsView.setInputEnabled(isFocused) + } +} + +/// The AppKit key-capture view backing ``HiveTerminalInputView``. +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). + 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) + } + 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(_) 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/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..1b4152cfbef2 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Sources/CmuxHiveUI/HiveViewerRootView.swift @@ -0,0 +1,154 @@ +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) + } + .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 + 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? { + terminalSessions[selection] + } + + private func ensureTerminalSession(for selection: HiveViewerSelection?) { + guard let selection, terminalSessions[selection] == nil, + let terminal = session.makeTerminalSession( + workspaceID: selection.workspaceID, + terminalID: selection.terminalID, + retryDelay: { @Sendable attempt in + await HiveReconnectBackoff().delay(attempt: attempt) + } + ) else { return } + terminalSessions[selection] = 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 + && 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) + } + + 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 nonisolated 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/HiveTerminalInputViewTests.swift b/Packages/macOS/CmuxHiveUI/Tests/CmuxHiveUITests/HiveTerminalInputViewTests.swift new file mode 100644 index 000000000000..1e5e7d3ed0e3 --- /dev/null +++ b/Packages/macOS/CmuxHiveUI/Tests/CmuxHiveUITests/HiveTerminalInputViewTests.swift @@ -0,0 +1,83 @@ +import AppKit +import Testing +@testable import CmuxHiveUI + +@MainActor +@Suite(.serialized) +struct HiveTerminalInputViewTests { + @Test func markedJapaneseTextSendsOnlyTheCommittedText() { + let view = HiveTerminalKeyCaptureNSView() + var sent: [String] = [] + view.actions = .init( + sendText: { sent.append($0) }, + sendSpecial: { _, _ in }, + sendControl: { _ in } + ) + view.setInputEnabled(true) + + view.setMarkedText( + "にほんご", + selectedRange: NSRange(location: 4, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + #expect(view.hasMarkedText()) + #expect(view.markedRange() == NSRange(location: 0, length: 4)) + #expect(sent.isEmpty) + + view.insertText( + NSAttributedString(string: "日本語"), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + #expect(sent == ["日本語"]) + #expect(!view.hasMarkedText()) + } + + @Test func disablingInputClearsCompositionAndSuppressesLateCommits() { + let view = HiveTerminalKeyCaptureNSView() + var sent: [String] = [] + view.actions = .init( + sendText: { sent.append($0) }, + sendSpecial: { _, _ in }, + sendControl: { _ in } + ) + view.setInputEnabled(true) + view.setMarkedText( + "かな", + selectedRange: NSRange(location: 2, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + view.setInputEnabled(false) + view.insertText("仮名", replacementRange: NSRange(location: NSNotFound, length: 0)) + + #expect(!view.hasMarkedText()) + #expect(sent.isEmpty) + } + + @Test func repeatedRenderUpdatesDoNotReclaimAnotherControlsFocus() throws { + _ = NSApplication.shared + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + let content = try #require(window.contentView) + let terminal = HiveTerminalKeyCaptureNSView() + let otherControl = HiveTerminalKeyCaptureNSView() + content.addSubview(terminal) + content.addSubview(otherControl) + + terminal.setInputEnabled(true) + #expect(window.firstResponder === terminal) + try #require(window.makeFirstResponder(otherControl)) + try #require(window.firstResponder === otherControl) + + terminal.setInputEnabled(true) + terminal.setInputEnabled(true) + + #expect(window.firstResponder === otherControl) + } +} 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/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 21c638881be8..4fdde3605dc8 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SettingCatalog.swift @@ -44,6 +44,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/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 5ca598204bac..2cf2fdce5187 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/SettingObservationStarting.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/SettingObservationStarting.swift @@ -14,5 +14,6 @@ extension DefaultsValueModel: SettingObservationStarting {} extension JSONValueModel: SettingObservationStarting {} extension SecretValueModel: SettingObservationStarting {} extension MobilePairingStatusModel: SettingObservationStarting {} +extension ComputersListModel: SettingObservationStarting {} extension MobilePhonePushSettingsModel: SettingObservationStarting {} extension DesktopNotificationAuthorizationModel: 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..980472d91ede --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/ComputersSettingsSnapshot.swift @@ -0,0 +1,123 @@ +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 current build has a transport-admission path for opening it. + public let canOpen: 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, + canOpen: Bool = true, + canPair: Bool, + presence: Presence, + detail: String? = nil + ) { + self.deviceID = deviceID + self.name = name + self.symbolName = symbolName + self.isThisMac = isThisMac + self.isPaired = isPaired + self.canOpen = canOpen + 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 + /// Whether the current host build exposes a usable viewer transport. + public let viewerTransportAvailable: Bool + + /// Creates a snapshot. + public init( + isSignedIn: Bool, + computers: [ComputersSettingsComputer], + lastRefreshFailed: Bool = false, + viewerTransportAvailable: Bool = true + ) { + self.isSignedIn = isSignedIn + self.computers = computers + self.lastRefreshFailed = lastRefreshFailed + self.viewerTransportAvailable = viewerTransportAvailable + } +} + +/// 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 + /// 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 + /// The computer advertises no dialable route to persist. + case noRoutes + /// The pairing could not be saved. + case failed +} + +/// 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 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 3015a93c8cb9..e20968c420c2 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -195,6 +195,42 @@ 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 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 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. + 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 @@ -437,6 +473,33 @@ 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 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 mintComputerPairingCode() async -> ComputersPairingCodeMintResult { .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/Navigation/SettingsSectionID.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/SettingsSectionID.swift index 1c087adc097a..ad53caafe5b6 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 /// Cloud Machines: persistent cloud VM plan and entry points. case cloudMachines /// Iroh relay policy, custom relays, and private-network routes. @@ -48,6 +50,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 .cloudMachines: return String(localized: "settings.section.cloudMachines", defaultValue: "Cloud") case .networking: return String(localized: "settings.section.networking", defaultValue: "Networking") case .sidebarAppearance: return "Sidebar" @@ -74,6 +77,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 .cloudMachines: return "cloud" case .networking: return "network" case .sidebarAppearance: return "sidebar.left" @@ -102,6 +106,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 push notifications alerts forwarding" + case .computers: return "computers devices macs remote pair unpair presence online offline tailscale hive" case .cloudMachines: return "cloud machines vm virtual machine persistent computer plan upgrade fleet sandbox" case .networking: return "iroh relay server private network tailscale vpn direct peer custom provider region" case .sidebarAppearance: return "sidebar details branches material terminal background" diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsSectionMountModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsSectionMountModel.swift index 4649034cf1ef..f1c7c4aebc55 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsSectionMountModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsSectionMountModel.swift @@ -46,7 +46,7 @@ public final class SettingsSectionMountModel { /// is an anchor inside the Browser section rather than a section of /// its own, so it never appears here. public static let displayOrder: [SettingsSectionID] = [ - .account, .app, .terminal, .textBox, .sleepyMode, .mobile, .cloudMachines, + .account, .app, .terminal, .textBox, .sleepyMode, .mobile, .computers, .cloudMachines, .networking, .sidebarAppearance, .customSidebars, .betaFeatures, .automation, .computerUse, .browser, .globalHotkey, .keyboardShortcuts, .workspaceColors, .settingsJSON, .reset, diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift index 8f6ee56d5a61..5aa2463d7101 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene+Sections.swift @@ -59,6 +59,15 @@ extension SettingsWindowRoot { MobileSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions) } + slot(.computers, proxy: proxy) { + ComputersSection( + hostActions: hostActions, + jsonStore: jsonStore, + catalog: catalog, + errorLog: runtime.errorLog + ) + } + slot(.cloudMachines, proxy: proxy) { // `DisableCloud` (MDM) and the rollout gate: no Cloud pane at all // while unavailable, not just no placeholder. 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..8d283dc95ea9 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ComputersSection.swift @@ -0,0 +1,465 @@ +import CMUXMobileCore +import CmuxFoundation +import CmuxSettings +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, or pair by typing the 6-digit code another +/// Mac is showing. +@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 = [] + /// 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 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 + + /// Creates the Computers section bound to the host bridge. + /// - 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 + } + + /// 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() + } + if model.current?.viewerTransportAvailable != false { + pairingCodeRow + pairResultCaption + 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, + // not condition-polling. + while !Task.isCancelled { + do { + try await ContinuousClock().sleep(for: .seconds(30)) + } catch { + break + } + guard !Task.isCancelled else { break } + 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) }, + open: { hostActions.openComputerViewer(deviceID: computer.deviceID) } + ) + } + } + if snapshot.lastRefreshFailed { + SettingsCardNote(String( + localized: "settings.computers.refreshFailed", + defaultValue: "Couldn't reach the device registry. Showing locally known computers." + )) + } + } + + @ViewBuilder + private var pairingCodeRow: some View { + SettingsCardRow( + configurationReview: .action, + searchAnchorID: "setting:computers:pairingCode", + String(localized: "settings.computers.pairingCode", defaultValue: "Pair with Code"), + subtitle: String( + localized: "settings.computers.pairingCode.subtitle", + defaultValue: "Type the 6-digit code shown by “Pair This Mac” on the other Mac." + ), + controlWidth: 200 + ) { + HStack(spacing: 8) { + TextField( + String(localized: "settings.computers.pairingCode.placeholder", defaultValue: "6-digit code"), + text: $codeInput + ) + .textFieldStyle(.roundedBorder) + .font(.body.monospacedDigit()) + .onChange(of: codeInput) { pairResult = nil } + .onSubmit { pairFromCode() } + .accessibilityIdentifier("SettingsComputersPairingCodeField") + + Button(String(localized: "settings.computers.pairingCode.pair", defaultValue: "Pair")) { + pairFromCode() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isPairingCode || CmxPairingCode.normalizedClaimInput(codeInput) == nil) + .accessibilityIdentifier("SettingsComputersPairingCodePairButton") + } + } + } + + @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 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 a 6-digit code, then type it into “Pair with Code” on another Mac." + ) + ) { + Button(String(localized: "settings.computers.showPairingCode.button", defaultValue: "Show Pairing Code")) { + mintPairingCode() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isMintingCode) + .accessibilityIdentifier("SettingsComputersShowPairingCodeButton") + } + } + + @ViewBuilder + 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() + } + } + + @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) + 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 mintPairingCode() { + guard !isMintingCode else { return } + isMintingCode = true + mintResult = nil + Task { + mintResult = await hostActions.mintComputerPairingCode() + isMintingCode = false + } + } + + private func pairFromCode() { + let code = codeInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isPairingCode, CmxPairingCode.normalizedClaimInput(code) != nil else { return } + isPairingCode = true + pairResult = nil + Task { + let result = await hostActions.pairComputer(code: code) + pairResult = result + if result == .paired { codeInput = "" } + isPairingCode = false + } + } + + /// `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 .minted, nil: + return nil + case .needsTailscale: + return String( + 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.showPairingCode.signedOut", + defaultValue: "Sign in to cmux first, then show a pairing code." + ) + case .failed: + return String( + localized: "settings.computers.showPairingCode.failed", + defaultValue: "Couldn't create a pairing code. Try again." + ) + } + } + + 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 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 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: + 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.") + } + } +} + +/// 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 + let open: () -> 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 { + let template = String( + localized: "settings.computers.row.thisMac", + defaultValue: "%@ (This Mac)" + ) + return String.localizedStringWithFormat(template, computer.name) + } + 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)) + let template = String( + localized: "settings.computers.row.lastSeen", + defaultValue: "Last seen %@" + ) + return String.localizedStringWithFormat(template, 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 { + if computer.canOpen { + Button(String(localized: "settings.computers.row.open", defaultValue: "Open")) { + open() + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(isPending) + .accessibilityIdentifier("SettingsComputersOpenButton-\(computer.deviceID)") + } + + 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/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+MobileViewportFit.swift index 0b630f1c6b84..9d49c21a86d6 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 ioMode.usesManualIO { + if ioMode.usesManualIO, !manualIOFontFitEnabled { // Remote/tmux mirrors keep legacy capping; their remote grid is - // authoritative and font fitting is intentionally out of v1 scope. + // authoritative and font fitting is intentionally out of v1 + // scope unless the owner explicitly opts in (Hive computer + // mirrors do so when their renderer can safely fit the grid). return legacyApplyMobileViewportLimit(surface: surface, columns: columns, rows: rows, reason: reason) } mobileViewportCellLimit = (columns: max(1, columns), rows: max(1, rows)) @@ -235,7 +237,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 ioMode.usesManualIO { + if ioMode.usesManualIO, !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+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 274226ebd171..32eba2a66706 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -299,6 +299,68 @@ 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 } + // Current renderer lifecycle uses the presentation phase rather than + // the old boolean `rendererRealized` mirror. If this surface was + // reclaimed (or born hidden), publish the guarded rebuild once the + // portal has usable drawable geometry; an already-presented renderer + // only needs the display-link/focus nudge below. + if rendererPresentationPhase != .presented { + ensureRendererPresented(presentationReady: isRendererPresentationReady) + } + 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=\(isRendererRealized ? 1 : 0) " + + "portalVisible=\(rendererPortalVisible ? 1 : 0) manualIO=\(ioMode.usesManualIO ? 1 : 0) " + + "focusDesired=\(desiredFocusState ? 1 : 0) size=\(size) display=\(display)" + } + /// Retries an unresolved presentation after Ghostty reports renderer activity. /// /// The app resolves the callback's stable surface id, then calls this only diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 8fb9ea797e31..c33f93ce5c71 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -260,6 +260,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`. ghostty's io /// thread can invoke the callback until `ghostty_surface_free` returns, so /// every teardown path releases this strictly after the native free diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f6c1b44d3bf3..fe0e8ca17542 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,6 +1,79 @@ { "sourceLanguage": "en", "strings": { + "agent.generic.notification.body.needsAttention": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%@ needs your attention"}},"ja":{"stringUnit":{"state":"translated","value":"%@ の確認が必要です"}}}}, + "agent.generic.notification.body.sentNotification": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%@ sent a notification"}},"ja":{"stringUnit":{"state":"translated","value":"%@ から通知が届きました"}}}}, + "cli.hooks.kimi.legacyCleanupWarning": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Warning: cmux hooks are active at %@, but cmux could not remove its legacy hook block from %@. Check that path and re-run `cmux hooks setup kimi` to finish cleanup."}},"ja":{"stringUnit":{"state":"translated","value":"警告: cmux hooks は %@ で有効ですが、従来の hook ブロックを %@ から削除できませんでした。そのパスを確認し、`cmux hooks setup kimi` を再実行してクリーンアップを完了してください。"}}}}, + "cli.hooks.kimi.legacyUninstallWarning": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Warning: cmux could not remove its legacy hook block from %@. Check that path and re-run `cmux hooks uninstall kimi` to finish cleanup."}},"ja":{"stringUnit":{"state":"translated","value":"警告: 従来の hook ブロックを %@ から削除できませんでした。そのパスを確認し、`cmux hooks uninstall kimi` を再実行してクリーンアップを完了してください。"}}}}, + "featureFlags.override.remote": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Remote"}},"ja":{"stringUnit":{"state":"translated","value":"リモート"}}}}, + "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":"このコンピュータはペアリングが解除されています。設定 › コンピュータで再度ペアリングしてください。"}}}}, + "hive.mirror.workspaceTitle": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"%1$@ — %2$@"}},"ja":{"stringUnit":{"state":"translated","value":"%1$@ — %2$@"}}}}, + "hive.scopePicker.allComputers": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"All Computers"}},"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.sidebar.status.noWorkspaces": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No open workspaces"}},"ja":{"stringUnit":{"state":"translated","value":"開いているワークスペースがありません"}}}}, + "hive.viewer.connecting": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Connecting…"}},"ja":{"stringUnit":{"state":"translated","value":"接続中…"}}}}, + "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.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.failed.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn't Connect"}},"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.noTerminal.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No Terminal"}},"ja":{"stringUnit":{"state":"translated","value":"ターミナルがありません"}}}}, + "hive.viewer.retry": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Retry"}},"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":"再接続中…"}}}}, + "hive.viewer.workspaces.empty": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No workspaces yet."}},"ja":{"stringUnit":{"state":"translated","value":"ワークスペースはまだありません。"}}}}, + "mobile.pairing.getApp.prompt": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Don't have it yet?"}},"ja":{"stringUnit":{"state":"translated","value":"まだお持ちでないですか?"}}}}, + "mobile.pairing.needsTailscale.body": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"This Mac has no Tailscale address, so your iPhone can't reach it. Install Tailscale on this Mac and your iPhone (same account), then refresh."}},"ja":{"stringUnit":{"state":"translated","value":"この Mac には Tailscale アドレスがないため、iPhone から接続できません。この Mac と iPhone の両方に(同じアカウントで)Tailscale をインストールしてから、更新してください。"}}}}, + "mobile.pairing.req.signIn.subtitle": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sign in to authorize this Mac for pairing."}},"ja":{"stringUnit":{"state":"translated","value":"この Mac のペアリングを許可するにはサインインしてください。"}}}}, + "mobile.pairing.req.signIn.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Signed in to cmux"}},"ja":{"stringUnit":{"state":"translated","value":"cmux にサインイン済み"}}}}, + "mobile.pairing.req.tailscale.hint": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Your Mac and iPhone both need Tailscale to connect over the internet."}},"ja":{"stringUnit":{"state":"translated","value":"インターネット経由で接続するには、Mac と iPhone の両方に Tailscale が必要です。"}}}}, + "mobile.pairing.req.tailscale.reachable": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Reachable over Tailscale."}},"ja":{"stringUnit":{"state":"translated","value":"Tailscale 経由で接続できます。"}}}}, + "mobile.pairing.req.tailscale.title": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Tailscale"}},"ja":{"stringUnit":{"state":"translated","value":"Tailscale"}}}}, + "mobile.pairing.signIn.button": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sign In"}},"ja":{"stringUnit":{"state":"translated","value":"サインイン"}}}}, + "mobile.pairing.signIn.connecting": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"جارٍ الاتصال…"}},"bs":{"stringUnit":{"state":"translated","value":"Povezivanje…"}},"da":{"stringUnit":{"state":"translated","value":"Forbinder…"}},"de":{"stringUnit":{"state":"translated","value":"Verbindung wird hergestellt…"}},"en":{"stringUnit":{"state":"translated","value":"Connecting…"}},"es":{"stringUnit":{"state":"translated","value":"Conectando…"}},"fr":{"stringUnit":{"state":"translated","value":"Connexion…"}},"it":{"stringUnit":{"state":"translated","value":"Connessione…"}},"ja":{"stringUnit":{"state":"translated","value":"接続しています…"}},"km":{"stringUnit":{"state":"translated","value":"កំពុងភ្ជាប់…"}},"ko":{"stringUnit":{"state":"translated","value":"연결 중…"}},"nb":{"stringUnit":{"state":"translated","value":"Kobler til…"}},"pl":{"stringUnit":{"state":"translated","value":"Łączenie…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Conectando…"}},"ru":{"stringUnit":{"state":"translated","value":"Подключение…"}},"th":{"stringUnit":{"state":"translated","value":"กำลังเชื่อมต่อ…"}},"tr":{"stringUnit":{"state":"translated","value":"Bağlanıyor…"}},"uk":{"stringUnit":{"state":"translated","value":"Підключення…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"正在连接…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"正在連接…"}}}}, + "mobile.pairing.signIn.openInBrowser": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"فتح في المتصفح"}},"bs":{"stringUnit":{"state":"translated","value":"Otvori u pregledniku"}},"da":{"stringUnit":{"state":"translated","value":"Åbn i browser"}},"de":{"stringUnit":{"state":"translated","value":"Im Browser öffnen"}},"en":{"stringUnit":{"state":"translated","value":"Open in Browser"}},"es":{"stringUnit":{"state":"translated","value":"Abrir en el navegador"}},"fr":{"stringUnit":{"state":"translated","value":"Ouvrir dans le navigateur"}},"it":{"stringUnit":{"state":"translated","value":"Apri nel browser"}},"ja":{"stringUnit":{"state":"translated","value":"ブラウザで開く"}},"km":{"stringUnit":{"state":"translated","value":"បើកក្នុងកម្មវិធីរុករក"}},"ko":{"stringUnit":{"state":"translated","value":"브라우저에서 열기"}},"nb":{"stringUnit":{"state":"translated","value":"Åpne i nettleser"}},"pl":{"stringUnit":{"state":"translated","value":"Otwórz w przeglądarce"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Abrir no navegador"}},"ru":{"stringUnit":{"state":"translated","value":"Открыть в браузере"}},"th":{"stringUnit":{"state":"translated","value":"เปิดในเบราว์เซอร์"}},"tr":{"stringUnit":{"state":"translated","value":"Tarayıcıda Aç"}},"uk":{"stringUnit":{"state":"translated","value":"Відкрити у браузері"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"在浏览器中打开"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"在瀏覽器中打開"}}}}, + "mobile.pairing.signIn.prompt": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sign in with your cmux account to pair your iPhone."}},"ja":{"stringUnit":{"state":"translated","value":"iPhone をペアリングするには、cmux アカウントでサインインしてください。"}}}}, + "mobile.pairing.signIn.slowHint": {"extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"قد تتوقف نافذة تسجيل الدخول في النظام عن الاستجابة. إذا لم يحدث شيء، فافتح تسجيل الدخول في المتصفح الافتراضي بدلاً من ذلك."}},"bs":{"stringUnit":{"state":"translated","value":"Sistemski prozor za prijavu može prestati odgovarati. Ako se ništa ne dogodi, otvorite prijavu u zadanom pregledniku."}},"da":{"stringUnit":{"state":"translated","value":"Systemets loginvindue kan holde op med at svare. Hvis der ikke sker noget, skal du i stedet åbne login i din standardbrowser."}},"de":{"stringUnit":{"state":"translated","value":"Das System-Anmeldefenster reagiert möglicherweise nicht mehr. Wenn nichts passiert, öffne die Anmeldung stattdessen in deinem Standardbrowser."}},"en":{"stringUnit":{"state":"translated","value":"The system sign-in window may stop responding. If nothing happens, open sign-in in your default browser instead."}},"es":{"stringUnit":{"state":"translated","value":"La ventana de inicio de sesión del sistema puede dejar de responder. Si no sucede nada, abre el inicio de sesión en tu navegador predeterminado."}},"fr":{"stringUnit":{"state":"translated","value":"La fenêtre de connexion du système peut ne plus répondre. Si rien ne se passe, ouvrez plutôt la connexion dans votre navigateur par défaut."}},"it":{"stringUnit":{"state":"translated","value":"La finestra di accesso di sistema potrebbe smettere di rispondere. Se non succede nulla, apri l’accesso nel browser predefinito."}},"ja":{"stringUnit":{"state":"translated","value":"システムのサインインウィンドウが応答しなくなることがあります。何も起きない場合は、デフォルトのブラウザでサインインを開いてください。"}},"km":{"stringUnit":{"state":"translated","value":"បង្អួចចូលគណនីរបស់ប្រព័ន្ធអាចឈប់ឆ្លើយតប។ ប្រសិនបើគ្មានអ្វីកើតឡើង សូមបើកការចូលគណនីក្នុងកម្មវិធីរុករកលំនាំដើមរបស់អ្នកជំនួសវិញ។"}},"ko":{"stringUnit":{"state":"translated","value":"시스템 로그인 창이 응답하지 않을 수 있습니다. 아무 반응이 없으면 기본 브라우저에서 로그인을 열어 주세요."}},"nb":{"stringUnit":{"state":"translated","value":"Systemets påloggingsvindu kan slutte å svare. Hvis ingenting skjer, åpner du påloggingen i standardnettleseren i stedet."}},"pl":{"stringUnit":{"state":"translated","value":"Systemowe okno logowania może przestać odpowiadać. Jeśli nic się nie dzieje, otwórz logowanie w domyślnej przeglądarce."}},"pt-BR":{"stringUnit":{"state":"translated","value":"A janela de login do sistema pode parar de responder. Se nada acontecer, abra o login no navegador padrão."}},"ru":{"stringUnit":{"state":"translated","value":"Системное окно входа может перестать отвечать. Если ничего не происходит, откройте вход в браузере по умолчанию."}},"th":{"stringUnit":{"state":"translated","value":"หน้าต่างลงชื่อเข้าของระบบอาจหยุดตอบสนอง หากไม่มีอะไรเกิดขึ้น ให้เปิดการลงชื่อเข้าในเบราว์เซอร์เริ่มต้นแทน"}},"tr":{"stringUnit":{"state":"translated","value":"Sistem oturum açma penceresi yanıt vermeyi durdurabilir. Hiçbir şey olmazsa oturum açmayı varsayılan tarayıcınızda açın."}},"uk":{"stringUnit":{"state":"translated","value":"Системне вікно входу може перестати відповідати. Якщо нічого не відбувається, відкрийте вхід у браузері за умовчанням."}},"zh-Hans":{"stringUnit":{"state":"translated","value":"系统登录窗口可能会停止响应。如果没有任何反应,请改用默认浏览器打开登录。"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"系統登入視窗可能會停止回應。如果沒有任何反應,請改用預設瀏覽器開啟登入。"}}}}, + "mobile.pairing.step.install": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Install cmux on your iPhone and open it."}},"ja":{"stringUnit":{"state":"translated","value":"iPhone に cmux をインストールして開きます。"}}}}, + "mobile.pairing.step.scan": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Tap Add device, then Scan QR Code, and point the camera at the code above."}},"ja":{"stringUnit":{"state":"translated","value":"「デバイスを追加」→「QR コードをスキャン」をタップし、上のコードにカメラを向けます。"}}}}, + "mobile.pairing.step.signIn": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sign in with the same account you use on this Mac."}},"ja":{"stringUnit":{"state":"translated","value":"この Mac と同じアカウントでサインインします。"}}}}, + "mobile.pairing.window.heading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Pair your iPhone"}},"ja":{"stringUnit":{"state":"translated","value":"iPhone をペアリング"}}}}, + "mobile.pairing.window.subheading": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Scan this code with the cmux app on your iPhone to sync your terminal workspaces."}},"ja":{"stringUnit":{"state":"translated","value":"iPhone の cmux アプリでこのコードをスキャンして、ターミナルワークスペースを同期します。"}}}}, + "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.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.codeNotFound": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"No computer is showing that code. Check the digits and that the code hasn't expired."}},"ja":{"stringUnit":{"state":"translated","value":"そのコードを表示しているコンピュータが見つかりません。数字と有効期限を確認してください。"}}}}, + "settings.computers.pairResult.failed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Pairing failed. Try again."}},"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 code."}},"ja":{"stringUnit":{"state":"translated","value":"cmux のペアリングコードではないようです。"}}}}, + "settings.computers.pairResult.loopback": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"That code belongs to this Mac. Enter it on the other Mac instead."}},"ja":{"stringUnit":{"state":"translated","value":"そのコードはこの Mac のものです。もう一方の Mac で入力してください。"}}}}, + "settings.computers.pairResult.noRoutes": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","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":"信頼できる接続経路がありません。Computers の登録行からペアリングし、両方の Mac で Tailscale が実行中であることを確認してください。"}}}}, + "settings.computers.pairResult.paired": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Paired."}},"ja":{"stringUnit":{"state":"translated","value":"ペアリングしました。"}}}}, + "settings.computers.pairingCode": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Pair with Code"}},"ja":{"stringUnit":{"state":"translated","value":"コードでペアリング"}}}}, + "settings.computers.pairingCode.pair": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Pair"}},"ja":{"stringUnit":{"state":"translated","value":"ペアリング"}}}}, + "settings.computers.pairingCode.placeholder": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"6-digit code"}},"ja":{"stringUnit":{"state":"translated","value":"6 桁のコード"}}}}, + "settings.computers.pairingCode.subtitle": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Type the 6-digit code shown by “Pair This Mac” on the other Mac."}},"ja":{"stringUnit":{"state":"translated","value":"もう一方の Mac の「この Mac をペアリング」に表示される 6 桁のコードを入力してください。"}}}}, + "settings.computers.presence.offline": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Offline"}},"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.presentation": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Open Computers In"}},"ja":{"stringUnit":{"state":"translated","value":"コンピュータの表示先"}}}}, + "settings.computers.presentation.sidebar": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Main Sidebar"}},"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.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.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.row.lastSeen": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Last seen %@"}},"ja":{"stringUnit":{"state":"translated","value":"最終確認 %@"}}}}, + "settings.computers.row.open": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Open"}},"ja":{"stringUnit":{"state":"translated","value":"開く"}}}}, + "settings.computers.row.pair": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Pair"}},"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.unpair": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Unpair"}},"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.button": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Show Pairing Code"}},"ja":{"stringUnit":{"state":"translated","value":"ペアリングコードを表示"}}}}, + "settings.computers.showPairingCode.expiresIn": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Expires in"}},"ja":{"stringUnit":{"state":"translated","value":"残り"}}}}, + "settings.computers.showPairingCode.failed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Couldn't create a pairing code. Try again."}},"ja":{"stringUnit":{"state":"translated","value":"ペアリングコードを作成できませんでした。もう一度お試しください。"}}}}, + "settings.computers.showPairingCode.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.showPairingCode.signedOut": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Sign in to cmux first, then show a pairing code."}},"ja":{"stringUnit":{"state":"translated","value":"先に cmux にサインインしてから、ペアリングコードを表示してください。"}}}}, + "settings.computers.showPairingCode.subtitle": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Show a 6-digit code, then type it into “Pair with Code” on another Mac."}},"ja":{"stringUnit":{"state":"translated","value":"6 桁のコードを表示し、別の Mac の「コードでペアリング」に入力してください。"}}}}, + "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.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"}}}}, + "settings.section.computers": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Computers"}},"ja":{"stringUnit":{"state":"translated","value":"コンピュータ"}}}}, "cloudTree.error.invalidPreviewURL": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate+CloudClients.swift b/Sources/AppDelegate+CloudClients.swift new file mode 100644 index 000000000000..9b464a954357 --- /dev/null +++ b/Sources/AppDelegate+CloudClients.swift @@ -0,0 +1,30 @@ +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) { + let cloudTunnel = makeCloudTunnelCoordinator() + cloudTunnelCoordinator = cloudTunnel + VMClient.bootstrap(auth: auth.coordinator) + TerminalController.shared.cloudTunnel = cloudTunnel + RemotesClient.bootstrap(auth: auth.coordinator) + AIAccountsClient.bootstrap(auth: auth.coordinator) + CoderouterClient.bootstrap(auth: auth.coordinator) + MachineUsageClient.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 bd58d6e986b8..a44432f326b9 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2478,16 +2478,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent (settingsRuntime.hostActions as? HostSettingsActions)?.setRunComputerUseOnboardingAction { [weak self] startingPoint in self?.computerUseUXCoordinator.presentOnboardingFromSettings(startingAt: startingPoint) } - let cloudTunnel = makeCloudTunnelCoordinator() - cloudTunnelCoordinator = cloudTunnel - VMClient.bootstrap(auth: auth.coordinator) - TerminalController.shared.cloudTunnel = cloudTunnel - RemotesClient.bootstrap(auth: auth.coordinator) - AIAccountsClient.bootstrap(auth: auth.coordinator) - CoderouterClient.bootstrap(auth: auth.coordinator) - MachineUsageClient.bootstrap(auth: auth.coordinator) - PhonePushClient.shared.configure(auth: auth.coordinator) - MobileHostService.shared.configure(auth: auth.coordinator) + // Keep all auth-backed clients on the single composition-root helper; + // it also wires the Hive registry/presence and DEV backup publishers. + configureCloudClients(auth: auth) caffeineController.onStateChange = { [weak self] enabled in self?.menuBarExtraController?.refreshForDebugControls() MobileHostService.emitEvent( @@ -2495,8 +2488,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent payload: ["enabled": enabled] ) } - DeviceRegistryClient.shared.configure(auth: auth.coordinator) - PresenceHeartbeatClient.shared.configure(auth: auth.coordinator) PhoneReplyInboxClient.shared.configure(auth: auth.coordinator) PhoneReplyInboxCoordinator.shared.configure(client: PhoneReplyInboxClient.shared) // Relayed phone replies share the direct RPC paste-and-submit path, @@ -2553,10 +2544,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } connectivityInvalidationSubscriberCoordinator.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) TerminalController.shared.attachAuth(coordinator: auth.coordinator, accountFlow: auth.accountFlow) TerminalController.shared.attachCaffeineController(caffeineController) TerminalController.shared.agentChatTranscriptService = agentChatTranscriptService @@ -4658,6 +4645,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 = liveRoute.window { @@ -10333,7 +10323,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Keep a strong reference so the window isn't deallocated. let controller = MainWindowController(window: window) - controller.onFrameRestorationCheckpoint = { [weak self] restoredWindow in self?.fitRestoredMainWindowFramesIfNeeded(windows: [restoredWindow]) } + controller.onFrameRestorationCheckpoint = { [weak self] restoredWindow in + self?.fitRestoredMainWindowFramesIfNeeded(windows: [restoredWindow]) + } controller.onGeometryChanged = { [weak self] changedWindow in self?.handleMainWindowGeometryChange(changedWindow) } controller.onClose = { [weak self, weak controller] closingWindow in guard let self, let controller else { return } @@ -13962,6 +13954,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/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift index 060fe1556139..626093b67729 100644 --- a/Sources/Auth/HostAccountFlow.swift +++ b/Sources/Auth/HostAccountFlow.swift @@ -153,6 +153,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { func signOut() async { await browserSignIn.signOut() + await HiveComputersService.shared.disconnectAll() isProActive = false canManageBilling = false } diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 9193cea314ee..b13a5d548020 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -22,7 +22,6 @@ final class DeviceRegistryClient { static let shared = DeviceRegistryClient() private let session = CmxCredentialedHTTPSession() - private let retryAfterGate = CmxRetryAfterGate() private var auth: AuthCoordinator? private var observeTask: Task? /// The scope (team + tag + routes) most recently registered, used to skip @@ -30,14 +29,27 @@ final class DeviceRegistryClient { /// account/team switch with unchanged routes still re-registers in the newly /// selected team instead of being deduped away. private var lastRegistration: Registration? + /// Monotonically identifies the latest registration intent. Responses + /// from an older POST must never overwrite the state for a newer pairing + /// code or team scope. + private var registrationGeneration = 0 /// The identity of a registration POST, for deduplication. struct Registration: Equatable { 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 @@ -68,10 +80,50 @@ 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) + registrationGeneration &+= 1 + let generation = registrationGeneration + activePairingCode = minted + // Force the POST: the code changed even if the routes did not. + lastRegistration = nil + guard await registerIfRoutesChanged( + routes: routes, + generation: generation, + force: true + ) else { + if registrationGeneration == generation { + activePairingCode = nil + lastRegistration = nil + } + return nil + } + return minted + } + private func startObserving() { observeTask?.cancel() // Registration is currently driven only by host-route changes. The dedup @@ -87,11 +139,14 @@ final class DeviceRegistryClient { } } - private func registerIfRoutesChanged(routes: [CmxAttachRoute]) async { - // Status, route, and foreground events share this gate. Cached routes - // remain valid while the server owns the next registration attempt. - guard await retryAfterGate.remainingSeconds() == nil else { return } - guard let auth else { return } + @discardableResult + private func registerIfRoutesChanged( + routes: [CmxAttachRoute], + generation: Int? = nil, + force: Bool = false + ) async -> Bool { + let requestGeneration = generation ?? registrationGeneration + guard let auth else { return false } // Await tokens FIRST: this both gates on "signed in" and waits for launch // auth bootstrap. `resolvedTeamID` is derived from `availableTeams`, which // is empty until bootstrap completes, so reading the team before this @@ -103,23 +158,38 @@ final class DeviceRegistryClient { do { tokens = try await auth.currentTokens() } catch { - return // not signed in → nothing to do + return false // not signed in → nothing to do } + guard requestGeneration == registrationGeneration else { return false } // Resolve the team AFTER bootstrap, and use that same scope for both the // dedup decision and the request header, so a team switch with unchanged // 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) - guard Self.shouldReRegister(previous: lastRegistration, current: registration) else { return } + // 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 force || Self.shouldReRegister(previous: lastRegistration, current: registration) else { + return false + } guard var comps = URLComponents( url: AuthEnvironment.deviceRegistryAPIBaseURL, resolvingAgainstBaseURL: false ) else { - return + return false } comps.path = (comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path) + "/api/devices" - guard let url = comps.url else { return } + guard let url = comps.url else { return false } let disclosureDate = Date() var bodyDict: [String: Any] = [ @@ -134,6 +204,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" @@ -152,15 +225,10 @@ final class DeviceRegistryClient { if (200...299).contains(http.statusCode) { // Only remember the scope once the server accepted it, so a // transient failure retries on the next status tick. + guard requestGeneration == registrationGeneration else { return false } lastRegistration = registration + return true } else { - if http.statusCode == 429 { - let seconds = CmxRetryAfterPolicy.seconds( - from: http, - defaultSeconds: CmxRetryAfterPolicy.defaultRateLimitSeconds - ) ?? CmxRetryAfterPolicy.defaultRateLimitSeconds - await retryAfterGate.extend(by: seconds) - } NSLog("cmux.deviceRegistry register failed status=%d", http.statusCode) } } @@ -170,6 +238,7 @@ final class DeviceRegistryClient { // stale routes with nothing to diagnose from. NSLog("cmux.deviceRegistry register unreachable: %@", String(describing: error)) } + return false } } diff --git a/Sources/CmuxAuxiliaryWindows.swift b/Sources/CmuxAuxiliaryWindows.swift new file mode 100644 index 000000000000..4df6c02104ff --- /dev/null +++ b/Sources/CmuxAuxiliaryWindows.swift @@ -0,0 +1,51 @@ +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.computerUse.onboarding", + "cmux.defaultTerminalRegistrationError", + "cmux.feedButtonStyleDebug", + "cmux.feedPreview", + "cmux.feedTextEditorDebug", + "cmux.fileExplorerStyleDebug", + "cmux.folderDragIcon", + "cmux.pdfPreviewChromeDebug", + "cmux.proBadgeDebug", + "cmux.recentlyClosedHistory", + "cmux.tabBarBackdropLab", + "cmux.taskManager", + "cmux.aboutTitlebarDebug", + "cmux.debugWindowControls", + "cmux.browserImportHintDebug", + "cmux.extensionSidebarInspector", + "cmux.sidebarDebug", + "cmux.menubarDebug", + "cmux.spinnerGallery", + "cmux.cloudTreeStyleGallery", + "cmux.backgroundDebug", + "cmux.startupAppearanceDebug", + "cmux.bonsplitTabBarDebug", + "cmux.titlebarLayoutDebug", + "cmux.devWindowDisplay", + "cmux.mobilePairingWindow", + "cmux.sidebarFooterIconBalanceDebug", + "cmux.hiveViewerWindow", +] + +/// 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) +} diff --git a/Sources/ContentView+AuthCommandPalette.swift b/Sources/ContentView+AuthCommandPalette.swift index 4ff96f15ecb1..101e0b7563eb 100644 --- a/Sources/ContentView+AuthCommandPalette.swift +++ b/Sources/ContentView+AuthCommandPalette.swift @@ -56,6 +56,7 @@ extension ContentView { } Task { @MainActor in await auth.accountFlow.signOut() + await HiveComputersService.shared.disconnectAll() } } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 66e64c62c36f..9a7a7c091910 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 CmuxNotifications import CmuxPanes import CmuxSettings @@ -1754,6 +1756,7 @@ struct ContentView: View { }, observedWindowReference: observedWindowReference, chromeBackgroundColor: windowAppearanceSnapshot.resolvedChromeBackgroundColor, + hiveScope: tabManager.hiveSidebarScopeModel, selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, sidebarRenderWorkerClient: $sidebarRenderWorkerClient ) @@ -1883,6 +1886,21 @@ struct ContentView: View { .opacity(sidebarSelectionState.selection == .tabs ? 1 : 0) .allowsHitTesting(sidebarSelectionState.selection == .tabs) .accessibilityHidden(sidebarSelectionState.selection != .tabs) + NotificationsPage( + isFocused: sidebarSelectionState.selection == .notifications, + isVisibleInUI: sidebarState.isVisible + ) + .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, @@ -11176,6 +11194,10 @@ struct VerticalTabsSidebar: View, Equatable { // unread invalidation boundary, so this O(workspaces) root stays inert. var notificationStore: TerminalNotificationStore { .shared } @EnvironmentObject var cmuxConfigStore: CmuxConfigStore + // Computer scope for the workspace list (This Mac / All Computers / one + // 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? @@ -11642,7 +11664,20 @@ struct VerticalTabsSidebar: View, Equatable { // Retain the native table identity while hidden without continuing the // O(workspaces) projection pipeline. Reveal rebuilds one authoritative // snapshot from the current model before the controller applies again. - let tabs = isPresented ? tabManager.tabs : [] + let scope = hiveScope.scope + let tabs: [Workspace] = if !isPresented { + [] + } else if scope == .allComputers + || (scope == .thisMac && !HiveComputerMirrorController.shared.hasMirrors) { + tabManager.tabs + } else { + 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 @@ -11743,20 +11778,50 @@ struct VerticalTabsSidebar: View, Equatable { 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: { + Task { @MainActor in + _ = await HiveComputerMirrorController.shared.attach( + deviceID: deviceScopeStatus.deviceID, + into: tabManager + ) + } + } + ) + } else if CmuxExtensionSidebarSelection.resolvesToDefaultSidebar(effectiveProviderId: effectiveExtensionSidebarProviderId) { workspaceScrollArea(renderContext: renderContext) } else { extensionSidebarScrollArea(renderContext: renderContext) } if isPresented { - SidebarFooter( - updateViewModel: updateViewModel, - fileExplorerState: fileExplorerState, - modifierKeyMonitor: modifierKeyMonitor, - onSendFeedback: onSendFeedback - ) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + HiveSidebarScopePicker(selection: $selection, scopeModel: hiveScope) + .padding(.leading, 8) + SidebarFooter( + updateViewModel: updateViewModel, + fileExplorerState: fileExplorerState, + modifierKeyMonitor: modifierKeyMonitor, + onSendFeedback: onSendFeedback + ) + .frame(maxWidth: .infinity, alignment: .leading) + } } } .accessibilityIdentifier("Sidebar") @@ -17871,7 +17936,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/HiveComputerMirrorController+Reconciliation.swift b/Sources/Hive/HiveComputerMirrorController+Reconciliation.swift new file mode 100644 index 000000000000..bfd4db862d73 --- /dev/null +++ b/Sources/Hive/HiveComputerMirrorController+Reconciliation.swift @@ -0,0 +1,214 @@ +import AppKit +import CmuxHive +import CmuxTerminal +import Foundation + +/// Applies remote topology and terminal frames to this controller’s native mirrors. +extension HiveComputerMirrorController { + /// 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 + } + + /// 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? + } + + func reconcile( + remote workspaces: [HiveRemoteWorkspace], + mirror: HiveDeviceMirror, + session: HiveRemoteMacSession + ) { + 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 + // 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) + deviceIDByWorkspaceID.removeValue(forKey: workspaceId) + 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], + let workspace = tabManager.workspacesById[workspaceId] { + deviceIDByWorkspaceID[workspaceId] = mirror.deviceID + let nextWorkspaceTitle = localizedWorkspaceTitle( + remoteTitle: remote.title, + computerName: mirror.computerName + ) + if workspace.title != nextWorkspaceTitle { + workspace.title = nextWorkspaceTitle + } + for terminal in remote.terminals { + if let panelID = mirror.panelIdByRemoteTerminalID[terminal.id] { + if workspace.panelTitle(panelId: panelID) != terminal.title { + workspace.updateRemoteTmuxTabTitle(panelId: panelID, title: terminal.title) + } + } + } + addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, session: session) + continue + } + let title = localizedWorkspaceTitle( + remoteTitle: remote.title, + computerName: mirror.computerName + ) + guard let workspace = tabManager.addWorkspaceIfActive( + title: title, + select: false, + autoWelcomeIfNeeded: false, + autoRefreshMetadata: false + ) else { continue } + // 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 + mirror.workspaceIdByRemoteID[remote.id] = workspace.id + deviceIDByWorkspaceID[workspace.id] = mirror.deviceID + let defaultPanelIds = Array(workspace.panels.keys) + addMissingTerminals(remote: remote, workspace: workspace, mirror: mirror, session: session) + for panelId in defaultPanelIds where workspace.panels[panelId] != nil { + _ = workspace.removeRemoteTmuxDisplayPane(panelId) + } + } + mirror.workspaceReadiness.publish( + workspaces.lazy.compactMap { mirror.workspaceIdByRemoteID[$0.id] }.first + ) + } + + /// 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 { + let template = String( + localized: "hive.mirror.workspaceTitle", + defaultValue: "%1$@ — %2$@" + ) + return String.localizedStringWithFormat(template, remoteTitle, computerName) + } + + private func addMissingTerminals( + remote: HiveRemoteWorkspace, + workspace: Workspace, + mirror: HiveDeviceMirror, + session: HiveRemoteMacSession + ) { + for (index, terminal) in remote.terminals.enumerated() + where mirror.terminalsByRemoteID[terminal.id] == nil { + let remoteTerminalID = terminal.id + 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, + title: terminal.title, + focus: false, + onInput: { input in + guard case let .bytes(data) = input else { return } + 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 an unrealized/zero-sized manual + // surface renders nothing; repaint from the cached full + // 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() + }, + onClose: { [weak self, weak mirror, weak terminalSession] in + terminalSession?.detach() + guard let self, let mirror else { return } + self.handleClosedTerminal(terminalID: remoteTerminalID, mirror: mirror) + } + ) 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. + 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. + 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" + ) + // 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) + } + terminalSession.attach() + mirror.terminalsByRemoteID[terminal.id] = terminalSession + mirror.panelIdByRemoteTerminalID[terminal.id] = panel.id + mirror.terminalIDsByRemoteWorkspaceID[remote.id, default: []].append(terminal.id) + } + } + + private func handleClosedTerminal(terminalID: String, mirror: HiveDeviceMirror) { + guard mirrorsByKey.values.contains(where: { $0 === mirror }) else { return } + mirror.terminalsByRemoteID.removeValue(forKey: terminalID)?.detach() + mirror.panelIdByRemoteTerminalID.removeValue(forKey: terminalID) + for workspaceID in mirror.terminalIDsByRemoteWorkspaceID.keys { + mirror.terminalIDsByRemoteWorkspaceID[workspaceID]?.removeAll { $0 == terminalID } + } + } +} diff --git a/Sources/Hive/HiveComputerMirrorController.swift b/Sources/Hive/HiveComputerMirrorController.swift new file mode 100644 index 000000000000..b16f09cad519 --- /dev/null +++ b/Sources/Hive/HiveComputerMirrorController.swift @@ -0,0 +1,374 @@ +import CMUXMobileCore +import CmuxHive +import CmuxMobileRPC +import CmuxSettings +import CmuxTerminal +import AppKit +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. +/// +/// 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() {} + + /// Mirror ownership is per computer *and* per main-window TabManager. + /// Two windows may scope to the same computer without stealing each + /// other's workspaces. + struct MirrorKey: Hashable, Sendable { + let deviceID: String + let tabManagerID: ObjectIdentifier + } + + private let rebindCoordinator = HiveMirrorRebindCoordinator() + + var mirrorsByKey: [MirrorKey: HiveDeviceMirror] = [:] + var deviceIDByWorkspaceID: [UUID: String] = [:] + + /// 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 + /// the selected workspace's terminals only (a device-wide repaint storms + /// replays on every click). + func workspaceSelected(_ workspaceId: UUID) { + for mirror in mirrorsByKey.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 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) + } + 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? { + deviceIDByWorkspaceID[workspaceId] + } + + /// Whether any window currently owns a remote mirror workspace. + var hasMirrors: Bool { + !mirrorsByKey.isEmpty + } + + /// 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) { + let deviceID = cmxCanonicalDeviceID(deviceID) + Task { @MainActor in + guard HiveComputersService.shared.viewerTransportAvailable else { return } + #if DEBUG + cmuxDebugLog("hive.presentViewer.begin device=\(deviceID.prefix(8))") + #endif + let presentation: ComputersPresentationMode + if let runtime = AppDelegate.shared?.settingsRuntime { + presentation = await runtime.jsonStore.value(for: runtime.catalog.computers.presentation) + } else { + presentation = .windows + } + #if DEBUG + cmuxDebugLog("hive.presentViewer.mode \(presentation)") + #endif + 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 + context.tabManager.hiveSidebarScopeModel.scope = .device(deviceID) + _ = 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) + let context = appDelegate.mainWindowContexts.values.first { $0.windowId == windowId } + guard let context else { + #if DEBUG + cmuxDebugLog("hive.presentViewer.windowContextMissing windowId=\(windowId)") + #endif + return + } + context.tabManager.hiveSidebarScopeModel.scope = .device(deviceID) + let attached = await HiveComputerMirrorController.shared.attach( + deviceID: deviceID, + into: context.tabManager + ) + _ = attached + #if DEBUG + cmuxDebugLog("hive.presentViewer.attached workspace=\(attached?.uuidString.prefix(8) ?? "nil")") + #endif + } + } + } + + /// Attaches (or re-focuses) a paired computer's workspaces as native + /// mirror workspaces in `tabManager`, keeping them reconciled with the + /// host's live topology. + @discardableResult + func attach( + deviceID: String, + into tabManager: TabManager, + replacing expectedMirror: HiveDeviceMirror? = nil + ) async -> UUID? { + let deviceID = cmxCanonicalDeviceID(deviceID) + guard !Task.isCancelled, !tabManager.isFinalizedForWindowClose else { return nil } + let selectionScope = tabManager.hiveSidebarScopeModel.scope + let shouldSelect = expectedMirror == nil || selectionScope == .device(deviceID) + || selectionScope == .allComputers + let key = MirrorKey(deviceID: deviceID, tabManagerID: ObjectIdentifier(tabManager)) + let previousMirror = mirrorsByKey[key] + if let expectedMirror, previousMirror !== expectedMirror { return nil } + if expectedMirror == nil, let existing = previousMirror { + if let firstId = existing.workspaceIdByRemoteID.values.first, + let workspace = tabManager.workspacesById[firstId] { + if tabManager.hiveSidebarScopeModel.scope == selectionScope { + tabManager.selectWorkspace(workspace) + } + return firstId + } + } + guard let session = await HiveComputersService.shared.embeddedSession(deviceID: deviceID) else { + #if DEBUG + cmuxDebugLog("hive.mirror.attach.noSession device=\(deviceID.prefix(8))") + #endif + return nil + } + guard !Task.isCancelled, !tabManager.isFinalizedForWindowClose else { return nil } + // A failed session remains cached so every window shares one transport, + // but an attach from the sidebar Retry action must explicitly restart + // that failed attempt before rebuilding the mirror around it. New + // sessions are already connecting; active/reconnecting sessions keep + // their in-flight lifecycle untouched. + _ = session.reconnectIfNeeded() + // Another Open/Retry call may have installed the same mirror while + // embeddedSession() suspended. Reuse that winner instead of creating + // an unreachable duplicate with live tasks and workspaces. + if mirrorsByKey[key] !== previousMirror { + guard expectedMirror == nil, let winner = mirrorsByKey[key] else { return nil } + if let firstId = winner.workspaceIdByRemoteID.values.first, + let workspace = tabManager.workspacesById[firstId] { + if tabManager.hiveSidebarScopeModel.scope == selectionScope { + tabManager.selectWorkspace(workspace) + } + return firstId + } + return nil + } + guard !Task.isCancelled, !tabManager.isFinalizedForWindowClose else { return nil } + let mirror = HiveDeviceMirror(deviceID: deviceID) + mirror.tabManager = tabManager + mirror.computerName = HiveComputersService.shared.directory?.computers + .first(where: { $0.deviceID == deviceID })?.displayName + ?? session.displayName + mirrorsByKey[key] = mirror + if let window = tabManager.window { + mirror.windowCloseObserver = NotificationCenter.default.addObserver( + forName: NSWindow.willCloseNotification, + object: window, + queue: .main + ) { [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) + } + } + } + + if let previousMirror { + // Transfer ownership before teardown so route churn never resets the user's scope. + await teardownMirror( + deviceID: deviceID, mirror: previousMirror, discardEmbeddedSession: false + ) + guard !Task.isCancelled, mirrorsByKey[key] === mirror, + !tabManager.isFinalizedForWindowClose else { return nil } + } + 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) + } + } + if let directory = HiveComputersService.shared.directory { + mirror.routeTask = Task { @MainActor [weak self, weak mirror, weak tabManager] in + for await computer in directory.updates(for: deviceID) { + 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 else { + 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.sourceRoutes + || best.instanceTag != session.expectedInstanceTag + || computer.isRegistryBacked != session.requiresHostIdentity else { + continue + } + // The replacement must outlive cancellation of this obsolete observer. + self.rebindCoordinator.rebind(key: key) { [weak self, weak mirror, weak tabManager] in + guard let self, let mirror, let tabManager else { return } + _ = await self.attach( + deviceID: deviceID, into: tabManager, replacing: mirror + ) + } + return + } + } + } + + // The deadline only bounds an empty attachment; readiness comes from reconciliation. + let firstID = await mirror.workspaceReadiness.wait { + try? await ContinuousClock().sleep(for: .seconds(10)) + } + guard !Task.isCancelled, !tabManager.isFinalizedForWindowClose, mirrorsByKey[key] === mirror, + let firstID, let workspace = tabManager.workspacesById[firstID] else { return nil } + if shouldSelect, tabManager.hiveSidebarScopeModel.scope == selectionScope { + tabManager.selectWorkspace(workspace) + } + return firstID + } + + /// Detaches a computer's mirrors: stops the streams and closes the + /// mirror workspaces. + func detach(deviceID: String, from tabManager: TabManager) async { + let deviceID = cmxCanonicalDeviceID(deviceID) + let key = MirrorKey(deviceID: deviceID, tabManagerID: ObjectIdentifier(tabManager)) + rebindCoordinator.cancel(key: key) + 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) + } + + private func detach(mirrorKey key: MirrorKey) async { + rebindCoordinator.cancel(key: key) + 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 { + rebindCoordinator.cancelAll() + 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 { + let deviceID = cmxCanonicalDeviceID(deviceID) + rebindCoordinator.cancelAll { $0.deviceID == deviceID } + 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: HiveDeviceMirror, + discardEmbeddedSession: Bool + ) async { + mirror.workspaceReadiness.finish() + mirror.reconcileTask?.cancel() + mirror.reconcileTask = nil + mirror.routeTask?.cancel() + mirror.routeTask = nil + if let observer = mirror.windowCloseObserver { + NotificationCenter.default.removeObserver(observer) + mirror.windowCloseObserver = nil + } + for (_, terminal) in mirror.terminalsByRemoteID { terminal.detach() } + if let tabManager = mirror.tabManager { + 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 { + deviceIDByWorkspaceID.removeValue(forKey: workspaceId) + guard let workspace = tabManager.workspacesById[workspaceId] else { continue } + tabManager.closeWorkspace(workspace) + } + } + if mirror.tabManager == nil { + for workspaceId in mirror.workspaceIdByRemoteID.values { + deviceIDByWorkspaceID.removeValue(forKey: workspaceId) + } + } + mirror.terminalsByRemoteID.removeAll() + mirror.workspaceIdByRemoteID.removeAll() + mirror.panelIdByRemoteTerminalID.removeAll() + mirror.terminalIDsByRemoteWorkspaceID.removeAll() + if discardEmbeddedSession { + await HiveComputersService.shared.discardEmbeddedSession(deviceID: deviceID) + } + } + +} diff --git a/Sources/Hive/HiveComputersService.swift b/Sources/Hive/HiveComputersService.swift new file mode 100644 index 000000000000..6f2e7d5a640e --- /dev/null +++ b/Sources/Hive/HiveComputersService.swift @@ -0,0 +1,227 @@ +import CMUXMobileCore +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). +/// +/// The composition root supplies configuration only — URLs, tokens, device +/// identity, loopback policy — and `HiveComposition` (in the CmuxHive +/// package) names the concrete client-stack types; this service uses shared +/// core identity values without constructing those clients. Follows 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() + + 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 + } + + /// Legacy Tailscale TCP has no cryptographic transport-admission handshake. + /// Keep viewer actions gated until that protocol is available; DEBUG + /// loopback remains reachable through the direct mirror path. + var viewerTransportAvailable: Bool { false } + + /// 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 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 { + directory = try composition.makeDirectory() + } catch { + Logger(subsystem: "com.cmux.app", category: "HiveComputersService") + .error("Paired-computer store unavailable: \(String(describing: error), privacy: .public)") + } + } + + /// 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? { + let deviceID = cmxCanonicalDeviceID(deviceID) + guard let auth, let directory else { return nil } + // 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 makeViewerSessionFromCurrentDirectory( + deviceID: deviceID, + auth: auth, + directory: directory + ) + } + + private func makeViewerSessionFromCurrentDirectory( + deviceID: String, + auth: AuthCoordinator, + directory: HiveComputerDirectory + ) -> 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 runtime = HiveSyncRuntime.network( + allowsLoopbackRoutes: Self.allowsLoopbackPairing, + stackAccessTokenProvider: { try await auth.accessToken() }, + stackAccessTokenForceRefresher: { try await auth.forceRefreshAccessToken() }, + stackAccessTokenForStatusProvider: { try? await auth.accessToken() } + ) + return HiveRemoteMacSession( + runtime: runtime, + macDeviceID: deviceID, + displayName: computer.displayName, + routes: best.routes, + sourceRoutes: best.routes, + retryDelay: { @Sendable attempt in + await HiveReconnectBackoff().delay(attempt: attempt) + }, + // Legacy Tailscale TCP has no cryptographic pre-bearer identity + // admission. Leave the compatibility evidence absent here so the + // viewer remains fail-closed until that transport handshake exists. + expectedInstanceTag: best.instanceTag, + requiresHostIdentity: computer.isRegistryBacked + ) + } + + /// 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] = [:] + 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? { + let deviceID = cmxCanonicalDeviceID(deviceID) + guard let directory else { return nil } + await refreshEmbeddedDirectory(directory) + if let existing = embeddedSessions[deviceID] { + let computer = directory.computers.first(where: { $0.deviceID == deviceID }) + let best = computer?.bestPairingRoutes + let bindingChanged = computer == nil + || computer?.isPaired != true + || best?.routes != existing.sourceRoutes + || best?.instanceTag != existing.expectedInstanceTag + || computer?.isRegistryBacked != existing.requiresHostIdentity + if bindingChanged { + embeddedSessions.removeValue(forKey: deviceID) + await existing.disconnect() + } else { + return existing + } + } + guard let auth else { return nil } + guard let session = makeViewerSessionFromCurrentDirectory( + deviceID: deviceID, + auth: auth, + directory: directory + ) 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 { + let deviceID = cmxCanonicalDeviceID(deviceID) + guard let session = embeddedSessions.removeValue(forKey: deviceID) else { return } + 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() + 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 + /// 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[cmxCanonicalDeviceID(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. + /// 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 + } + + private static func normalizedBaseURL(_ url: URL) -> String { + let raw = url.absoluteString + return raw.hasSuffix("/") ? String(raw.dropLast()) : raw + } +} diff --git a/Sources/Hive/HiveDeviceMirror.swift b/Sources/Hive/HiveDeviceMirror.swift new file mode 100644 index 000000000000..4d685760bb44 --- /dev/null +++ b/Sources/Hive/HiveDeviceMirror.swift @@ -0,0 +1,27 @@ +import CmuxHive +import Foundation + +/// A single window’s live workspace and terminal ownership for one remote Mac. +@MainActor +final class HiveDeviceMirror { + let deviceID: String + var computerName: String = "" + weak var tabManager: TabManager? + var reconcileTask: Task? + var routeTask: Task? + let workspaceReadiness = HiveMirrorWorkspaceReadiness() + var windowCloseObserver: NSObjectProtocol? + /// 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] = [:] + /// Remote terminal ids per remote workspace id (repaint scoping and + /// dead-terminal pruning). + var terminalIDsByRemoteWorkspaceID: [String: [String]] = [:] + + init(deviceID: String) { + self.deviceID = deviceID + } +} diff --git a/Sources/Hive/HiveSidebarConnectionStatusView.swift b/Sources/Hive/HiveSidebarConnectionStatusView.swift new file mode 100644 index 000000000000..833c60c14959 --- /dev/null +++ b/Sources/Hive/HiveSidebarConnectionStatusView.swift @@ -0,0 +1,88 @@ +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:)`) may fail before a +/// session object exists, 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 + /// 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 .idle: + self = .neverAttempted + case nil: + // A device-scoped sidebar can reach this state when admission + // fails before a session object exists. Surface a retryable + // failure instead of leaving an indefinite spinner. + self = .failed + case .connecting, .reconnecting: + self = .connecting + case .connected: + self = .connectedEmpty + case .failed: + self = .failed + } + } + } + + 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: + Image(systemName: "wifi.exclamationmark") + .font(.title2) + .foregroundStyle(.secondary) + Text(String(localized: "hive.viewer.failed.title", defaultValue: "Couldn't Connect")) + .font(.callout) + 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) + .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/Hive/HiveViewerWindowController.swift b/Sources/Hive/HiveViewerWindowController.swift new file mode 100644 index 000000000000..c98bce0c853f --- /dev/null +++ b/Sources/Hive/HiveViewerWindowController.swift @@ -0,0 +1,174 @@ +import AppKit +import CMUXMobileCore +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' 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 final class OpenViewer { + let window: NSWindow + var session: HiveRemoteMacSession + var routeTask: Task? + + init(window: NSWindow, session: HiveRemoteMacSession) { + self.window = window + self.session = session + } + } + + private var viewersByDeviceID: [String: OpenViewer] = [:] + + private override init() { + super.init() + } + + /// Opens (or focuses) the viewer window for one paired computer. + func show(deviceID: String) { + let deviceID = cmxCanonicalDeviceID(deviceID) + 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 + } + await presentWindow(deviceID: deviceID, session: session) + } + } + + private func presentWindow(deviceID: String, session: HiveRemoteMacSession) async { + if let existing = viewersByDeviceID[deviceID] { + existing.window.makeKeyAndOrderFront(nil) + // Two show requests can race while the first session is being + // created. The losing session was never mounted in a window, so + // explicitly tear it down instead of leaving its transport/tasks + // alive after returning to the existing viewer. + if existing.session !== session { + await session.disconnect() + } + return + } + let appearanceMode = UserDefaults.standard.string(forKey: AppearanceSettings.appearanceModeKey) + let hostingController = NSHostingController( + rootView: makeRootView(session: session, appearanceMode: appearanceMode) + ) + let window = NSWindow(contentViewController: hostingController) + window.title = session.displayName + 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) + window.center() + window.isReleasedWhenClosed = false + window.delegate = self + 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] in + var currentSession = session + for await computer in directory.updates(for: deviceID) { + guard let self, + let entry = self.viewersByDeviceID[deviceID], + entry.session === currentSession else { return } + guard let computer else { + await self.close(deviceID: deviceID) + return + } + guard computer.isPaired, let best = computer.bestPairingRoutes else { + await self.close(deviceID: deviceID) + return + } + 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 { + await self.close(deviceID: deviceID) + return + } + replacement.connect() + entry.session = replacement + entry.window.contentViewController = NSHostingController( + rootView: self.makeRootView( + session: replacement, + appearanceMode: appearanceMode + ) + ) + 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 } + viewersByDeviceID.removeValue(forKey: entry.key) + entry.value.routeTask?.cancel() + let session = entry.value.session + Task { @MainActor in + 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.routeTask?.cancel() + entry.window.close() + await entry.session.disconnect() + } + } +} diff --git a/Sources/HostSettingsActions+Computers.swift b/Sources/HostSettingsActions+Computers.swift new file mode 100644 index 000000000000..0a6f24bdf8a2 --- /dev/null +++ b/Sources/HostSettingsActions+Computers.swift @@ -0,0 +1,179 @@ +import CmuxHive +import CmuxSettings +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, + viewerTransportAvailable: HiveComputersService.shared.viewerTransportAvailable + ) + } + + 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, + viewerTransportAvailable: HiveComputersService.shared.viewerTransportAvailable + )) + } + 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 HiveComputersService.shared.viewerTransportAvailable else { return .noRoutes } + guard let directory = computersDirectory else { return .failed } + return Self.pairResult(await directory.pair(deviceID: deviceID)) + } + + func pairComputer(code: String) async -> ComputersPairResult { + guard HiveComputersService.shared.viewerTransportAvailable else { return .noRoutes } + guard let directory = computersDirectory else { return .failed } + return Self.pairResult(await directory.pair(code: code)) + } + + func unpairComputer(deviceID: String) async { + guard let directory = computersDirectory else { return } + guard await directory.unpair(deviceID: deviceID) else { return } + await HiveComputerMirrorController.shared.detach(deviceID: deviceID) + await HiveViewerWindowController.shared.close(deviceID: deviceID) + } + + func openComputerViewer(deviceID: String) { + // 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 + /// device registry (alongside this Mac's live routes), so another Mac can + /// pair by typing the code — nothing to copy between machines. + /// + /// The listener is brought up first so the registration carries dialable + /// routes for the claiming Mac to persist. + func mintComputerPairingCode() async -> ComputersPairingCodeMintResult { + guard HiveComputersService.shared.viewerTransportAvailable else { return .failed } + 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 } + 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: supportedRoutes + ) else { return .failed } + return .minted(code: minted.code, expiresAt: minted.expiresAt) + } + + // MARK: - Mapping (pure) + + /// Map the merged directory rows into the settings package's snapshot. + static func computersSnapshot( + computers: [HiveComputer], + isSignedIn: Bool, + lastRefreshFailed: Bool, + viewerTransportAvailable: Bool = true + ) -> ComputersSettingsSnapshot { + ComputersSettingsSnapshot( + isSignedIn: isSignedIn, + computers: computers.map(Self.computerRow), + lastRefreshFailed: lastRefreshFailed, + viewerTransportAvailable: viewerTransportAvailable + ) + } + + 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, + canOpen: HiveComputersService.shared.viewerTransportAvailable + && computer.hasViewerSupportedRoute, + canPair: HiveComputersService.shared.viewerTransportAvailable + && !computer.isThisComputer && computer.isPairableHost && !computer.isPaired + && computer.isOwnedByCurrentUser + && computer.hasViewerSupportedRoute, + 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 .codeNotFound: return .codeNotFound + case .loopbackRejected: return .loopbackRejected + case .accountMismatch: return .accountMismatch + case .unsupportedManualRoute: return .noRoutes + 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/Mobile/Pairing/MobilePairingWindowController.swift b/Sources/Mobile/Pairing/MobilePairingWindowController.swift index 59efcc15d471..60fad7115835 100644 --- a/Sources/Mobile/Pairing/MobilePairingWindowController.swift +++ b/Sources/Mobile/Pairing/MobilePairingWindowController.swift @@ -15,7 +15,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/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4e8129d0348e..965943c254c8 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -215,6 +215,10 @@ enum SessionSidebarSelection: String, Codable, Sendable, Equatable { // Notifications moved from a window-level overlay to a pane tab. // Never persist the retired overlay selection. self = .tabs + 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/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index e619aea0f25a..0b13d9d16ea5 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 cloudMachines case networking case sidebarAppearance @@ -38,6 +39,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 .cloudMachines: return String(localized: "settings.section.cloudMachines", defaultValue: "Cloud") case .networking: @@ -83,6 +86,8 @@ enum SettingsNavigationTarget: String, CaseIterable, Identifiable { return "moon.zzz" case .mobile: return "iphone" + case .computers: + return "desktopcomputer" case .cloudMachines: return "cloud" case .networking: @@ -114,50 +119,7 @@ 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 .cloudMachines: - return "\(title) cloud machines vm virtual machine persistent computer plan upgrade fleet" - 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 .computerUse: - return "\(title) computer use cua accessibility screen recording permissions cursor mcp agents driver menu bar onboarding" - 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..e703d0b79ed6 --- /dev/null +++ b/Sources/SettingsNavigationTarget+SearchText.swift @@ -0,0 +1,53 @@ +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) \(SettingsSearchAliasIndex.sectionAliases(for: self))" + case .cloudMachines: + return "\(title) cloud machines vm virtual machine persistent computer plan upgrade fleet" + 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 .computerUse: + return "\(title) computer use cua accessibility screen recording permissions cursor mcp agents driver menu bar onboarding" + 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 fa41a210d428..194b8b101b6e 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 .cloudMachines: return localized("settings.search.alias.section.cloudMachines", defaultValue: "cloud machines vm virtual machine persistent computer sandbox plan upgrade fleet freestyle") case .networking: diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index b35fe8791b92..1037e3976720 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -181,6 +181,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? @@ -2805,6 +2807,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 diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift index 299f74563eb9..431ab8cf11a8 100644 --- a/Sources/TerminalController+ControlMobileHostContext.swift +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -81,6 +81,78 @@ 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) + } + guard HiveComputersService.shared.viewerTransportAvailable else { + return .err( + code: "transport_unavailable", + message: String( + localized: "hive.viewer.error.connection", + defaultValue: "The other Mac couldn't be reached. Check that it is online and paired, then try again." + ), + 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) })])) + } + + /// `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 = tabManager.hiveSidebarScopeModel.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/Sources/TerminalController.swift b/Sources/TerminalController.swift index e8ece90b2fe8..001aaed9eb19 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1569,6 +1569,7 @@ class TerminalController { let semaphore = DispatchSemaphore(value: 0) Task { @MainActor [weak self] in await self?.accountFlow?.signOut(timeout: 5) + await HiveComputersService.shared.disconnectAll() semaphore.signal() } semaphore.wait() diff --git a/Sources/TerminalNotificationPolicyAgentContext.swift b/Sources/TerminalNotificationPolicyAgentContext.swift index 42500828df81..513bc61cd770 100644 --- a/Sources/TerminalNotificationPolicyAgentContext.swift +++ b/Sources/TerminalNotificationPolicyAgentContext.swift @@ -32,4 +32,3 @@ struct TerminalNotificationPolicyAgentContext: Codable, Sendable, Equatable { self.sessionId = sessionId } } - diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index be25a2e2e23b..f1d633ecc13e 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -1,12 +1,194 @@ 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 +} + +/// 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) +} + +/// 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 { + @Published var scope: HiveSidebarScope = .thisMac + + /// 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 +/// 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] = [] + + @ObservedObject var scopeModel: HiveSidebarScopeModel + + var body: some View { + if presentation == .sidebar, !computers.isEmpty { + Menu { + scopeButton(scope: .thisMac, title: thisMacTitle) + scopeButton(scope: .allComputers, title: allComputersTitle) + Divider() + ForEach(computers) { computer in + scopeButton(scope: .device(computer.id), title: 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() } + } + } + + @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] + } + if deviceIDs.isEmpty { + selectFirstVisibleWorkspace(scope: scope, manager: manager) + return + } + Task { @MainActor in + await attachDevices(deviceIDs, scope: scope, manager: 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 { + 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 { + 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 { + HiveComputersService.shared.viewerTransportAvailable + && $0.isPaired && !$0.isThisComputer + } + .map { HiveScopeComputer(id: $0.deviceID, name: $0.displayName) } + 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) + } + } + + private func attachDevices( + _ deviceIDs: [String], + scope: HiveSidebarScope, + manager: TabManager + ) async { + let workerCount = min(8, max(deviceIDs.count, 1)) + await withTaskGroup(of: Void.self) { group in + for workerIndex in 0.. some View { diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index d794210b7e34..f68756dfba2a 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -4479,6 +4479,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos /// tmux pane ids (multi-pane mirror ✕) with a close-time activity query or /// confirmation in flight, so click spam can't double-kill or stack dialogs. private var pendingRemoteTmuxPaneCloseIds: Set = [] + /// Immediate cleanup hooks for display panes owned by a live mirror. + private var remoteTmuxDisplayPaneCloseHandlers: [UUID: @MainActor @Sendable () -> Void] = [:] /// User-initiated close attempts, distinct from internal close/move flows. private var explicitUserCloseTabIds: Set = [] @@ -9347,7 +9349,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos allowTextBoxFocusDefault: Bool = true, onInput: @escaping @Sendable (TerminalManualInput) -> Void, keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil, - onResize: (@MainActor @Sendable (_ columns: Int, _ rows: Int) -> Void)? = nil + onResize: (@MainActor @Sendable (_ columns: Int, _ rows: Int) -> Void)? = nil, + onClose: (@MainActor @Sendable () -> Void)? = nil ) -> TerminalPanel? { guard !isRetiredFromOwningTabManager else { return nil } let newPanel = performRemoteTmuxMirrorMutation { () -> TerminalPanel? in @@ -9371,6 +9374,9 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = title + if let onClose { + remoteTmuxDisplayPaneCloseHandlers[newPanel.id] = onClose + } guard let newTabId = bonsplitController.createTab( title: title, @@ -9380,6 +9386,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + remoteTmuxDisplayPaneCloseHandlers.removeValue(forKey: newPanel.id) return nil } bindSurface(newTabId, toPanelId: newPanel.id) @@ -14094,6 +14101,10 @@ extension Workspace: BonsplitDelegate { #endif let panel = panels[panelId] + let remoteTmuxDisplayPaneCloseHandler = isDetaching + ? nil + : remoteTmuxDisplayPaneCloseHandlers.removeValue(forKey: panelId) + remoteTmuxDisplayPaneCloseHandler?() _ = consumeCloseHistoryEligibility(tabId: tabId, panelId: panelId) let transferredRemoteCleanupConfiguration = transferredRemoteCleanupConfigurationsByPanelId[panelId] let preservesSurfaceForDetach = isDetaching && panel != nil diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index f63caeeb6712..806f7ecb5d2e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1617,49 +1617,6 @@ private struct MainWindowBootstrapView: View { } } -private let cmuxAuxiliaryWindowIdentifiers: Set = [ - "cmux.settings", - "cmux.about", - "cmux.licenses", - "cmux.browser-popup", - "cmux.browserProfilePopoverDebug", - "cmux.configEditor", - "cmux.computerUse.onboarding", - "cmux.defaultTerminalRegistrationError", - "cmux.feedButtonStyleDebug", - "cmux.feedPreview", - "cmux.feedTextEditorDebug", - "cmux.fileExplorerStyleDebug", - "cmux.folderDragIcon", - "cmux.pdfPreviewChromeDebug", - "cmux.proBadgeDebug", - "cmux.recentlyClosedHistory", - "cmux.tabBarBackdropLab", - "cmux.taskManager", - "cmux.aboutTitlebarDebug", - "cmux.debugWindowControls", - "cmux.browserImportHintDebug", - "cmux.extensionSidebarInspector", - "cmux.sidebarDebug", - "cmux.menubarDebug", - "cmux.spinnerGallery", - "cmux.cloudTreeStyleGallery", - "cmux.backgroundDebug", - "cmux.startupAppearanceDebug", - "cmux.bonsplitTabBarDebug", - "cmux.titlebarLayoutDebug", - "cmux.devWindowDisplay", - "cmux.mobilePairingWindow", - "cmux.sidebarFooterIconBalanceDebug", -] - -/// 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) { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8ad124ecc5bc..34ec97988ada 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -211,6 +211,7 @@ D35A00000000000000000001 /* AppDelegate+BrowserDesignMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000001 /* AppDelegate+BrowserDesignMode.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 */; }; 7A0CE1000000000000000045 /* AppDelegate+CloudTunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A0CE1000000000000000019 /* AppDelegate+CloudTunnel.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 */; }; @@ -822,6 +823,7 @@ A99940010000000000000001 /* CMUXAuthCore in Frameworks */ = {isa = PBXBuildFile; productRef = 29813FE5A6CBC1019289A251 /* CMUXAuthCore */; }; 53750003A0B1C2D3E4F50003 /* CmuxAuthRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 53750002A0B1C2D3E4F50002 /* CmuxAuthRuntime */; }; A99940020000000000000001 /* 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 */; }; 947194719471947194719472 /* CmuxBundledBinPathIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947194719471947194719471 /* CmuxBundledBinPathIntegrationTests.swift */; }; @@ -1005,6 +1007,10 @@ 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 */; }; + C41FE00000000000000000B3 /* CmuxHiveUI in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A4 /* CmuxHiveUI */; }; + C41FE00000000000000000B4 /* CmuxHiveUI in Frameworks */ = {isa = PBXBuildFile; productRef = C41FE00000000000000000A4 /* CmuxHiveUI */; }; A11C00010000000000000001 /* CmuxHostedSystemSymbolImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C00010000000000000002 /* CmuxHostedSystemSymbolImage.swift */; }; A11C00040000000000000001 /* CmuxHostedSystemSymbolImageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A11C00040000000000000002 /* CmuxHostedSystemSymbolImageTests.swift */; }; C0DE46010000000000000001 /* CMUXInstalledExtensionSidebarHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE46010000000000000002 /* CMUXInstalledExtensionSidebarHostView.swift */; }; @@ -1606,10 +1612,17 @@ C4160A020000000000000001 /* HistoryMenuCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A020000000000000002 /* HistoryMenuCoordinator.swift */; }; A10348030000000000000001 /* HistoryMenuCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10348030000000000000002 /* HistoryMenuCoordinatorTests.swift */; }; A10348020000000000000001 /* HistoryMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10348020000000000000002 /* HistoryMenuState.swift */; }; + C41FE00000000000000000FA /* HiveComputerMirrorController+Reconciliation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000EA /* HiveComputerMirrorController+Reconciliation.swift */; }; + C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */; }; + C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D2 /* HiveComputersService.swift */; }; + C41FE00000000000000000F9 /* HiveDeviceMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000E9 /* HiveDeviceMirror.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 */; }; B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */; }; F17A00000000000000000002 /* HostLatencyTrace.swift in Sources */ = {isa = PBXBuildFile; fileRef = F17A00000000000000000001 /* HostLatencyTrace.swift */; }; + C41FE00000000000000000C1 /* HostSettingsActions+Computers.swift in Sources */ = {isa = PBXBuildFile; fileRef = C41FE00000000000000000D1 /* HostSettingsActions+Computers.swift */; }; B7F100010000000000000001 /* HostSettingsActions+NotificationSounds.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F100010000000000000002 /* HostSettingsActions+NotificationSounds.swift */; }; CD0CFE6200000000CD0CFE62 /* HostSettingsActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */; }; 809100000000000000000001 /* HostSettingsShortcutNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809100000000000000000002 /* HostSettingsShortcutNotificationTests.swift */; }; @@ -2392,6 +2405,7 @@ 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 */; }; D10000000000000000A00022 /* SettingsNetworkingBehaviorUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000A00023 /* SettingsNetworkingBehaviorUITests.swift */; }; A50019B0 /* SettingsSearchAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B1 /* SettingsSearchAliases.swift */; }; A50019B2 /* SettingsSearchIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50019B3 /* SettingsSearchIndexTests.swift */; }; @@ -3706,6 +3720,7 @@ D35B00000000000000000001 /* AppDelegate+BrowserDesignMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+BrowserDesignMode.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 = ""; }; 7A0CE1000000000000000019 /* AppDelegate+CloudTunnel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "AppDelegate+CloudTunnel.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 = ""; }; @@ -4293,6 +4308,7 @@ 875200000000000000000002 /* cmuxApp+SurfaceNavigationMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+SurfaceNavigationMenu.swift"; sourceTree = ""; }; 80410000000000000000000E /* cmuxApp+WorkspaceCommandHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "cmuxApp+WorkspaceCommandHelpers.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 = ""; }; 947194719471947194719471 /* CmuxBundledBinPathIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxBundledBinPathIntegrationTests.swift; sourceTree = ""; }; A12123000000000000000006 /* CMUXCLI+AgentHibernationSessionEnd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHibernationSessionEnd.swift"; sourceTree = ""; }; 61AD48A9E6C3F1BE2B547FFE /* CMUXCLI+AgentHookCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookCatalog.swift"; sourceTree = ""; }; @@ -5001,10 +5017,17 @@ C4160A020000000000000002 /* HistoryMenuCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryMenuCoordinator.swift; sourceTree = ""; }; A10348030000000000000002 /* HistoryMenuCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryMenuCoordinatorTests.swift; sourceTree = ""; }; A10348020000000000000002 /* HistoryMenuState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryMenuState.swift; sourceTree = ""; }; + C41FE00000000000000000EA /* HiveComputerMirrorController+Reconciliation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "HiveComputerMirrorController+Reconciliation.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 = ""; }; + C41FE00000000000000000E9 /* HiveDeviceMirror.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HiveDeviceMirror.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 = ""; }; B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedInspectorDockControlScriptTests.swift; sourceTree = ""; }; F17A00000000000000000001 /* HostLatencyTrace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/HostLatencyTrace.swift; sourceTree = ""; }; + C41FE00000000000000000D1 /* HostSettingsActions+Computers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "HostSettingsActions+Computers.swift"; sourceTree = ""; }; B7F100010000000000000002 /* HostSettingsActions+NotificationSounds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HostSettingsActions+NotificationSounds.swift"; sourceTree = ""; }; CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostSettingsActions.swift; sourceTree = ""; }; 809100000000000000000002 /* HostSettingsShortcutNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostSettingsShortcutNotificationTests.swift; sourceTree = ""; }; @@ -5774,6 +5797,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 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 = ""; }; D10000000000000000A00023 /* SettingsNetworkingBehaviorUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNetworkingBehaviorUITests.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 = ""; }; @@ -6808,6 +6832,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D0F1A0000000000000000003 /* CmuxFilePreviewCore in Frameworks */, CF00F00000000000000000A3 /* CmuxFoundation in Frameworks */, C617000000000000000000A3 /* CmuxGit in Frameworks */, + C41FE00000000000000000B1 /* CmuxHive in Frameworks */, + C41FE00000000000000000B3 /* CmuxHiveUI in Frameworks */, 1A0B0C0D0E0F101112132013 /* CmuxIrohTransport in Frameworks */, 1A0B0C0D0E0F101112132113 /* CmuxIrxTransport in Frameworks */, 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */, @@ -6903,6 +6929,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef CC0033E15A1B2C3D4E5F0012 /* CmuxDiffComments in Frameworks */, D0F1A0000000000000000004 /* CmuxFilePreviewCore in Frameworks */, 5E2701040000000000000004 /* CmuxFoundation in Frameworks */, + C41FE00000000000000000B2 /* CmuxHive in Frameworks */, + C41FE00000000000000000B4 /* CmuxHiveUI in Frameworks */, 1A0B0C0D0E0F101112132014 /* CmuxIrohTransport in Frameworks */, 1A0B0C0D0E0F101112132114 /* CmuxIrxTransport in Frameworks */, EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */, @@ -7168,6 +7196,20 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef path = Mobile; sourceTree = ""; }; + C41FE00000000000000000E1 /* Hive */ = { + isa = PBXGroup; + children = ( + C41FE00000000000000000D2 /* HiveComputersService.swift */, + C41FE00000000000000000D9 /* HiveComputerMirrorController.swift */, + C41FE00000000000000000E9 /* HiveDeviceMirror.swift */, + C41FE00000000000000000EA /* HiveComputerMirrorController+Reconciliation.swift */, + C41FE00000000000000000D5 /* HiveViewerWindowController.swift */, + 5E8D5AF4AAC86680531F2BAC /* HiveSidebarConnectionStatusView.swift */, + ); + name = Hive; + path = Hive; + sourceTree = ""; + }; 90813BBC44D79A5D2D0A2F09 /* Surfaces */ = { isa = PBXGroup; children = ( @@ -7378,7 +7420,9 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F50030020000000000000002 /* TitlebarInteractiveControlModifier.swift */, C0DE34020000000000000003 /* CmuxHelpCommands.swift */, C0DE34020000000000000004 /* CmuxHelpResource.swift */, + C41FE00000000000000000D6 /* CmuxAuxiliaryWindows.swift */, A50019A1 /* SettingsNavigation.swift */, + C41FE00000000000000000D4 /* SettingsNavigationTarget+SearchText.swift */, D5671402D5671402D5671402 /* SettingsNavigation+TerminalScrollSpeed.swift */, A50019B1 /* SettingsSearchAliases.swift */, D3610C010000000000000002 /* CmuxSettingsJSONPathSupport.swift */, @@ -8376,6 +8420,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F4350A130000000000000001 /* AppBundleIconPersistencePolicy.swift */, D1320AA0D1320AA0D1320AA4 /* AppIconDockTilePlugin.swift */, A5001090 /* AppDelegate.swift */, + C41FE00000000000000000D3 /* AppDelegate+CloudClients.swift */, 7A0CE1000000000000000019 /* AppDelegate+CloudTunnel.swift */, C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */, D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */, @@ -8860,6 +8905,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5001222 /* WindowAccessor.swift */, B7F100010000000000000002 /* HostSettingsActions+NotificationSounds.swift */, CD0CFE6300000000CD0CFE63 /* HostSettingsActions.swift */, + C41FE00000000000000000D1 /* HostSettingsActions+Computers.swift */, B7F101010000000000000002 /* NotificationSoundAgentRegistryLoader.swift */, F6572001A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift */, F6572011A1B2C3D4E5F60718 /* SurfaceResumeCommandCanonicalizer+CodexUpdateCheck.swift */, @@ -9186,6 +9232,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef CC0033E15A1B2C3D4E5F0004 /* TerminalController+CommentsCommands.swift */, 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */, C57B00050000000000000002 /* CmuxExtensionSidebarSelection+CustomSidebarName.swift */, + C41FE00000000000000000E1 /* Hive */, 59065015952E1B5BE5E23519 /* Mobile */, 31B04B98376C9BA8B9E44DCB /* TerminalViewportUITestRecorder.swift */, ); @@ -9225,11 +9272,11 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B9000003A1B2C3D4E5F60719 /* CLI */ = { isa = PBXGroup; children = ( + C11218000000000000000051 /* CLIError.swift */, 805500000000000000000003 /* BrowserValueTextFormatter.swift */, B9000001A1B2C3D4E5F60719 /* cmux.swift */, C2538002A1B2C3D4E5F60719 /* CMUXCLI+TerminalCreation.swift */, F97300010000000000000001 /* FreestyleInteractiveShellScript.swift */, - C11218000000000000000051 /* CLIError.swift */, REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */, C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */, C0DE60C0000000000000A033 /* CMUXCLI+CoderouterPassthrough.swift */, @@ -10434,6 +10481,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef CC0DE30000000000000000A2 /* CmuxRemoteSession */, A8BD195031FC4B82B4354297 /* StackAuth */, EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, + C41FE00000000000000000A2 /* CmuxHive */, + C41FE00000000000000000A4 /* CmuxHiveUI */, EFB18E3B3099DFE2ECA57571 /* CmuxSimulatorStreamKit */, 1A0B0C0D0E0F101112132002 /* CmuxMobileTransport */, ); @@ -10521,6 +10570,12 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef ); name = cmuxTests; packageProductDependencies = ( + A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, + AC4A6E7C4A700001AC4A0002 /* CmuxAgentChat */, + EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, + C41FE00000000000000000A2 /* CmuxHive */, + C41FE00000000000000000A4 /* CmuxHiveUI */, + A77A0002A1B2C3D4E5F60001 /* CmuxAgentJournal */, 29813FE5A6CBC1019289A251 /* CMUXAuthCore */, 53750002A0B1C2D3E4F50002 /* CmuxAuthRuntime */, A5B00002A1B2C3D4E5F60718 /* CMUXAgentLaunch */, @@ -10529,7 +10584,6 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 1A0B0C0D0E0F101112132012 /* CmuxIrohTransport */, 1A0B0C0D0E0F101112132112 /* CmuxIrxTransport */, C1A072000000000000000002 /* CmuxMobileRPC */, - EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */, 1A0B0C0D0E0F101112132002 /* CmuxMobileTransport */, CA52A005CA52A005CA52A005 /* CmuxCanvas */, 5E2701040000000000000001 /* Sentry */, @@ -10640,6 +10694,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 5E55200000000000000000B1 /* XCLocalSwiftPackageReference "CmuxWindowing" */, 5E55500000000000000000E1 /* XCLocalSwiftPackageReference "CmuxCommandPalette" */, C617000000000000000000A1 /* XCLocalSwiftPackageReference "CmuxGit" */, + C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */, + C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */, C5B6A10000000000000000A1 /* XCLocalSwiftPackageReference "CmuxSidebarGit" */, E3B7A30000000000000000B1 /* XCLocalSwiftPackageReference "CmuxSidebar" */, E3B7A30000000000000000C1 /* XCLocalSwiftPackageReference "CmuxBrowser" */, @@ -11050,6 +11106,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D35A00000000000000000001 /* AppDelegate+BrowserDesignMode.swift in Sources */, C4A570010000000000000001 /* AppDelegate+CanvasShortcutRouting.swift in Sources */, C4160A030000000000000001 /* AppDelegate+ClosedItemHistory.swift in Sources */, + C41FE00000000000000000C3 /* AppDelegate+CloudClients.swift in Sources */, 7A0CE1000000000000000045 /* AppDelegate+CloudTunnel.swift in Sources */, C54860050000000000000001 /* AppDelegate+CmuxNavigationDeepLinks.swift in Sources */, C3677004000000000000001 /* AppDelegate+CmuxSSHURL.swift in Sources */, @@ -11422,6 +11479,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 875200000000000000000012 /* cmuxApp+SurfaceNavigationMenu.swift in Sources */, 80410000000000000000000D /* cmuxApp+WorkspaceCommandHelpers.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 */, @@ -11813,9 +11871,16 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A10348010000000000000001 /* HistoryMenuActions.swift in Sources */, C4160A020000000000000001 /* HistoryMenuCoordinator.swift in Sources */, A10348020000000000000001 /* HistoryMenuState.swift in Sources */, + C41FE00000000000000000FA /* HiveComputerMirrorController+Reconciliation.swift in Sources */, + C41FE00000000000000000C9 /* HiveComputerMirrorController.swift in Sources */, + C41FE00000000000000000C2 /* HiveComputersService.swift in Sources */, + C41FE00000000000000000F9 /* HiveDeviceMirror.swift in Sources */, + 7DD3FE43EB968D44E5652135 /* HiveSidebarConnectionStatusView.swift in Sources */, + C41FE00000000000000000C5 /* HiveViewerWindowController.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */, F17A00000000000000000002 /* HostLatencyTrace.swift in Sources */, + C41FE00000000000000000C1 /* HostSettingsActions+Computers.swift in Sources */, B7F100010000000000000001 /* HostSettingsActions+NotificationSounds.swift in Sources */, CD0CFE6200000000CD0CFE62 /* HostSettingsActions.swift in Sources */, C0DEA7720000000000000001 /* InternalFlagsWindow.swift in Sources */, @@ -12339,6 +12404,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 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 */, @@ -14715,6 +14781,14 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CMUXMobileCore; }; + C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxHive; + }; + C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/macOS/CmuxHiveUI; + }; 0C69E6A5FA9B0513E68D5757 /* XCLocalSwiftPackageReference "CmuxSimulatorStreamKit" */ = { isa = XCLocalSwiftPackageReference; relativePath = Packages/Shared/CmuxSimulatorStreamKit; @@ -14729,11 +14803,11 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef }; 1A0B0C0D0E0F101112132001 /* XCLocalSwiftPackageReference "CmuxMobileTransport" */ = { isa = XCLocalSwiftPackageReference; - relativePath = Packages/iOS/CmuxMobileTransport; + relativePath = Packages/Shared/CmuxMobileTransport; }; C1A072000000000000000001 /* XCLocalSwiftPackageReference "CmuxMobileRPC" */ = { isa = XCLocalSwiftPackageReference; - relativePath = Packages/iOS/CmuxMobileRPC; + relativePath = Packages/Shared/CmuxMobileRPC; }; 28B798BB9086C8E6B60C3355 /* XCLocalSwiftPackageReference "stack-auth-swift-sdk-prerelease" */ = { isa = XCLocalSwiftPackageReference; @@ -15289,6 +15363,16 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef package = 0C69E6A5FA9B0513E68DF443 /* XCLocalSwiftPackageReference "CMUXMobileCore" */; productName = CMUXMobileCore; }; + C41FE00000000000000000A2 /* CmuxHive */ = { + isa = XCSwiftPackageProductDependency; + package = C41FE00000000000000000A1 /* XCLocalSwiftPackageReference "CmuxHive" */; + productName = CmuxHive; + }; + C41FE00000000000000000A4 /* CmuxHiveUI */ = { + isa = XCSwiftPackageProductDependency; + package = C41FE00000000000000000A3 /* XCLocalSwiftPackageReference "CmuxHiveUI" */; + productName = CmuxHiveUI; + }; EFB18E3B3099DFE2ECA57571 /* CmuxSimulatorStreamKit */ = { isa = XCSwiftPackageProductDependency; package = 0C69E6A5FA9B0513E68D5757 /* XCLocalSwiftPackageReference "CmuxSimulatorStreamKit" */; diff --git a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme index b37eac3de257..11b184b14342 100644 --- a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme +++ b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-ci.xcscheme @@ -12,7 +12,8 @@ - + + diff --git a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme index 60f365ca2b73..2927da4ecaa4 100644 --- a/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme +++ b/cmux.xcodeproj/xcshareddata/xcschemes/cmux-unit.xcscheme @@ -12,7 +12,8 @@ - + + diff --git a/cmux.xcworkspace/contents.xcworkspacedata b/cmux.xcworkspace/contents.xcworkspacedata index a87f5c881dc9..6329f8148879 100644 --- a/cmux.xcworkspace/contents.xcworkspacedata +++ b/cmux.xcworkspace/contents.xcworkspacedata @@ -34,6 +34,30 @@ + + + + + + + + + + + + + + + + @@ -74,42 +98,18 @@ - - - - - - - - - - - - - - - - @@ -168,6 +168,12 @@ + + + + diff --git a/cmuxTests/CLILocalTmuxReviewRegressionTests.swift b/cmuxTests/CLILocalTmuxReviewRegressionTests.swift index df5f460d72da..88d22d4e3e39 100644 --- a/cmuxTests/CLILocalTmuxReviewRegressionTests.swift +++ b/cmuxTests/CLILocalTmuxReviewRegressionTests.swift @@ -71,9 +71,9 @@ extension CLINotifyProcessIntegrationRegressionTests { let fakeTmux = """ #!/bin/sh - printf '%s|%s|%s|%s|%s|%s|%s|%s|%s|%s\n' \ - "$TMUX" "$CMUX_LOCAL_TMUX" "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" \ - > "$CMUX_TEST_OUTPUT" + printf '%s|%s|%s|' "$TMUX" "$CMUX_LOCAL_TMUX" "$1" > "$CMUX_TEST_OUTPUT" + eval "set -- $6" + printf '%s|%s|%s\n' "$1" "$2" "$3" >> "$CMUX_TEST_OUTPUT" """ try Data(fakeTmux.utf8).write(to: fakeTmuxURL) XCTAssertEqual(chmod(fakeTmuxURL.path, 0o755), 0) @@ -102,7 +102,7 @@ extension CLINotifyProcessIntegrationRegressionTests { XCTAssertEqual(result.status, 0, result.stderr) let invocation = try String(contentsOf: outputURL, encoding: .utf8) XCTAssertTrue(invocation.hasPrefix("|1|-S|"), invocation) - XCTAssertTrue(invocation.contains("attach-session -t $7"), invocation) + XCTAssertEqual(invocation, "|1|-S|attach-session|-t|$7\n") } func testLocalTmuxClientListingUsesPopulatedTTYTarget() throws { 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) + } } diff --git a/cmuxTests/MobileHostIrohAdmissionTests.swift b/cmuxTests/MobileHostIrohAdmissionTests.swift index c8cd8265d6f5..19fe59e5fb47 100644 --- a/cmuxTests/MobileHostIrohAdmissionTests.swift +++ b/cmuxTests/MobileHostIrohAdmissionTests.swift @@ -722,7 +722,7 @@ extension MobileHostAuthorizationTests { @Test func testIrohApplicationLaneQuotasReserveArtifactCapacity() { #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentTerminalLaneCount == 4) #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentArtifactLaneCount == 1) - #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentLaneCount == 5) + #expect(MobileHostIrohApplicationLaneRouter.maximumConcurrentLaneCount == 7) var quota = MobileHostIrohApplicationLaneQuota() let terminalIDs = (0..<5).map { _ in UUID() } diff --git a/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index 0dd4d3c0f813..5d997fdd8218 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -155,6 +155,7 @@ final class SidebarLazyLayoutScaleTests { onNewTab: {}, observedWindowReference: WeakWindowReference(), chromeBackgroundColor: .black, + hiveScope: tabManager.hiveSidebarScopeModel, selection: .constant(.tabs), selectedTabIds: .constant([]), lastSidebarSelectionIndex: .constant(nil), @@ -237,7 +238,6 @@ final class SidebarLazyLayoutScaleTests { } } - /// Mounting the sidebar with 300 workspaces must realize only the rows a /// single viewport needs. Realizing all of them is the #5323/#6210 defeat: /// at scale, every subsequent update pays an O(N) layout pass and the main diff --git a/cmuxTests/SurfaceCatalogTests.swift b/cmuxTests/SurfaceCatalogTests.swift index 6a6522ec9c5b..4207af970624 100644 --- a/cmuxTests/SurfaceCatalogTests.swift +++ b/cmuxTests/SurfaceCatalogTests.swift @@ -1175,4 +1175,106 @@ struct SurfaceCatalogTests { #expect(provider.closedTerminals.isEmpty) #expect(provider.closedRemoteWorkspaces == ["ws_empty"]) } + + @Test func cloudRenameReceiptMetadataDoesNotReplaceCanonicalProjections() throws { + let machine = CmuxTuiSurfaceProviderTests.machine + let catalog = SurfaceCatalog() + let provider = FakeProvider(machine: machine) + catalog.register(provider) + let before = try cloudRenameState(name: "main", revision: 10) + catalog.replaceCloudState(before, resources: CmuxTuiSnapshotParser.resources(from: before), info: provider.info) + let receipt = CloudVMCursor(generation: "rename-generation", revision: 11) + let write = CloudVMPendingMutation( + kind: .workspaceRename, remoteWorkspaceID: "ws_main", name: "Renamed", receipt: receipt + ) + catalog.updateCloudPendingWrites(on: machine, writes: [write], from: provider) + + // A receipt is pending-write metadata, never an optimistic daemon graph. + #expect(catalog.cloudStates[machine] == before) + #expect(catalog.cloudStateObservations[machine]?.pendingWrites == [write]) + #expect(catalog.snapshot.resources.first { $0.id.key == "term_build" }?.remoteWorkspace?.name == "main") + + let after = try cloudRenameState(name: "Renamed", revision: 11) + catalog.replaceCloudState(after, resources: CmuxTuiSnapshotParser.resources(from: after), info: provider.info) + let build = try #require(catalog.snapshot.resources.first { $0.id.key == "term_build" }) + #expect(catalog.cloudStates[machine] == after) + #expect(catalog.cloudStateObservations[machine]?.pendingWrites == nil) + #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first { $0.id == "ws_main" }?.name == "Renamed") + #expect(build.remoteWorkspace?.name == "Renamed") + #expect(build.remoteViews?.first { $0.workspace.id == "ws_main" }?.workspace.name == "Renamed") + #expect(build.remoteViews?.first { $0.workspace.id == "ws_api" }?.workspace.name == "api") + #expect(catalog.snapshot.resources.first { $0.id.key == "term_shell" }?.remoteWorkspace?.name == "api") + } + + @Test func cursorlessMachineUpdatePreservesConfirmedCloudWorkspaceName() throws { + let machine = CmuxTuiSurfaceProviderTests.machine + let catalog = SurfaceCatalog() + let provider = FakeProvider(machine: machine) + catalog.register(provider) + let before = try cloudRenameState(name: "before", revision: 1) + catalog.replaceCloudState(before, resources: CmuxTuiSnapshotParser.resources(from: before), info: provider.info) + let beforeInfo = try #require(catalog.snapshot.machines.first) + let after = try cloudRenameState(name: "after", revision: 2) + catalog.replaceCloudState(after, resources: CmuxTuiSnapshotParser.resources(from: after), info: provider.info) + + // Status refreshes have no cursor and may still carry cached workspace names. + catalog.updateMachine(beforeInfo, from: provider) + #expect(catalog.cloudStates[machine] == after) + #expect(catalog.snapshot.machines.first?.remoteWorkspaces?.first { $0.id == "ws_main" }?.name == "after") + #expect(catalog.snapshot.resources.first { $0.id.key == "term_build" }?.remoteWorkspace?.name == "after") + } + + @Test func cloudRenameReceiptRejectsStaleReadsWithoutPoisoningEqualCursorRecovery() { + let receipt = CloudVMCursor(generation: "rename-generation", revision: 11) + // The provider owns admission; the catalog only installs accepted canonical state. + for revision in [UInt64(9), 10] { + #expect(CloudVMRemoteMutationReceiptDecision.resolve( + receipt: receipt, + incoming: CloudVMCursor(generation: receipt.generation, revision: revision), + targetMatches: false + ) == .rejectStale) + } + #expect(CloudVMRemoteMutationReceiptDecision.resolve( + receipt: receipt, incoming: receipt, targetMatches: false + ) == .rejectConflict) + #expect(CloudVMRemoteMutationReceiptDecision.resolve( + receipt: receipt, incoming: receipt, targetMatches: true + ) == .accept) + #expect(CloudVMRemoteMutationReceiptDecision.resolve( + receipt: receipt, + incoming: CloudVMCursor(generation: receipt.generation, revision: 12), + targetMatches: false + ) == .accept) + } + + @Test(.timeLimit(.minutes(1))) + func olderCloudRenameFailureCannotClearNewerIntent() async throws { + let coordinator = CloudRenameCoordinator() + let key = CloudRenameCoordinator.Key.workspace(machine: .cloud("vivid-newt"), id: "ws_main") + let gate = AsyncStream.makeStream() + defer { gate.continuation.finish() } + let first = coordinator.enqueue(key: key, pendingName: "first") { throw CancellationError() } + let second = coordinator.enqueue(key: key, pendingName: "second") { + for await _ in gate.stream { break } + } + defer { first.cancel(); second.cancel() } + + await #expect(throws: CancellationError.self) { try await first.value } + #expect(coordinator.pendingName(for: key) == "second") + gate.continuation.yield(()) + try await second.value + #expect(coordinator.pendingName(for: key) == nil) + } + + private func cloudRenameState(name: String, revision: UInt64) throws -> CloudVMState { + var snapshot = CmuxTuiSurfaceProviderTests.sessionSnapshot + snapshot["cursor"] = ["generation": "rename-generation", "revision": String(revision)] + snapshot["workspaces"] = [ + ["id": "ws_main", "name": name, "focused": true], + ["id": "ws_api", "name": "api", "focused": false], + ] + return try #require(CmuxTuiSnapshotParser.state( + fromSnapshot: snapshot, machine: CmuxTuiSurfaceProviderTests.machine + )) + } } 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/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5c7c7ad93d38..55486e97f9f7 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f54f15ee24a7e4c103df21de60f84415c8f2f094b2aeba559786a12987421823", + "originHash" : "1bd19173a5d60b7086688349f00306cc30d414804f66ded1c1f29aabe9911024", "pins" : [ { "identity" : "highlightr", diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index fc802d2cd79b..69264026dfb7 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3e2058bf90bf2c1caef8ad94956461e721f87976fa6c0d452a1c08b310c1d36d", + "originHash" : "d322e4659355b474183f567e037167d0676688159b5f74d4e6d6746656e2cc49", "pins" : [ { "identity" : "highlightr", diff --git a/ios/cmuxPackage/Package.swift b/ios/cmuxPackage/Package.swift index a81fd5a8af00..af84bfb938e5 100644 --- a/ios/cmuxPackage/Package.swift +++ b/ios/cmuxPackage/Package.swift @@ -36,17 +36,17 @@ let package = Package( .package(path: "../../Packages/iOS/CmuxMobileBrowserStream"), .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/Shared/CmuxMobileTerminalKit"), + .package(path: "../../Packages/Shared/CmuxMobileTransport"), .package(path: "../../Packages/iOS/CmuxMobileToast"), - .package(path: "../../Packages/iOS/CmuxMobileTerminalKit"), - .package(path: "../../Packages/iOS/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..20becc2bec10 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`). @@ -112,7 +112,7 @@ P1 is not bundled here because it carries a build-toolchain change for all CI an 1. **This PR**: spike (`experiments/iroh-swift-ffi-spike/`) plus this design doc. No app/runtime changes, no reload needed. 2. **PR 2, packaging**: `native/cmux-iroh/` crate (FFI grown to caller-provided keys and error-kind codes), `scripts/ensure-cmux-iroh.sh`, Rust toolchain in CI, xcframework linked into both apps but referenced by nothing. Proves the build matrix everywhere without behavior change. -3. **PR 3, phone dial lane**: `CmxIrohByteTransport` in `Packages/iOS/CmuxMobileTransport` plus registration, behind a feature flag (DEBUG default ON, release default OFF). Includes the EndpointId pin gate from the security section: pin at first trust in `MobilePairedMacStore`, refuse to send Stack tokens to a non-matching EndpointId, explicit re-trust UX on change. Unit tests with the existing transport test doubles; one loopback-style integration test dialing a local iroh listener. +3. **PR 3, phone dial lane**: `CmxIrohByteTransport` in `Packages/Shared/CmuxMobileTransport` plus registration, behind a feature flag (DEBUG default ON, release default OFF). Includes the EndpointId pin gate from the security section: pin at first trust in `MobilePairedMacStore`, refuse to send Stack tokens to a non-matching EndpointId, explicit re-trust UX on change. Unit tests with the existing transport test doubles; one loopback-style integration test dialing a local iroh listener. 4. **PR 4, Mac host lane**: `MobileHostByteConnection` seam extraction in `MobileHostService` (pure refactor commit first, NWConnection adapter only, zero behavior change), then the iroh accept loop, Keychain key custody, route publication in `MobileRouteResolver`, registry propagation. Same feature flag. 5. **PR 5, default-on plus Settings**: flag becomes a real Settings toggle pair (iroh on by default; "Also publish Tailscale/LAN routes" on by default for compat), strings en+ja, docs. Flip of the Tailscale-publication default is a later standalone change. diff --git a/scripts/check-pbxproj.sh b/scripts/check-pbxproj.sh index 51e0930cc57d..98744716970b 100755 --- a/scripts/check-pbxproj.sh +++ b/scripts/check-pbxproj.sh @@ -3,6 +3,7 @@ # Fails when: # - objectVersion drifts from the pinned value (Xcode major leak) # - the file is not normalized (someone bypassed the pre-commit hook) +# - the native macOS property-list parser rejects the project syntax set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -29,3 +30,9 @@ if [[ "$actual" != "$EXPECTED_OBJECT_VERSION" ]]; then fi python3 "$SCRIPT_DIR/normalize-pbxproj.py" --check "$PBXPROJ" + +# Normalization does not parse OpenStep syntax. Use the native parser on Macs +# to catch malformed groups and unquoted file names before invoking Xcode. +if [[ "$(uname -s)" == "Darwin" ]]; then + plutil -lint "$PBXPROJ" +fi diff --git a/scripts/ci/run-iroh-tailscale-compatibility-gate.sh b/scripts/ci/run-iroh-tailscale-compatibility-gate.sh index 6dd4b7841552..2c52e82be0b5 100755 --- a/scripts/ci/run-iroh-tailscale-compatibility-gate.sh +++ b/scripts/ci/run-iroh-tailscale-compatibility-gate.sh @@ -103,7 +103,7 @@ run_package_gate() { run_app_host_gate run_package_gate \ - Packages/iOS/CmuxMobileShell \ + Packages/Shared/CmuxMobileShell \ 'ReconnectRouteSelectionTests/(legacyMacWithoutIrohFailsClosedInsteadOfSendingBearerOverTCP|legacySavedMacWithoutPublishedIrohIsRetainedAndRequestsMacUpdate|preIrohPairingContinuesOverItsExactTailscaleRouteAfterIOSUpgrade|rejectedIrohReconnectNeverDowngradesToRawTailscale|storedReconnectPinsIrohAndExcludesRawFallbacks|switchToLegacySavedMacUpgradesFromRegistryWithoutRescan)' \ 6 \ 'CmuxMobileShellTests.ReconnectRouteSelectionTests/legacyMacWithoutIrohFailsClosedInsteadOfSendingBearerOverTCP()' \ @@ -114,14 +114,14 @@ run_package_gate \ 'CmuxMobileShellTests.ReconnectRouteSelectionTests/switchToLegacySavedMacUpgradesFromRegistryWithoutRescan()' run_package_gate \ - Packages/iOS/CmuxMobileRPC \ + Packages/Shared/CmuxMobileRPC \ 'MobileCoreRPCClientTests/(admittedIrohRequestCarriesNoStackOrAttachCredential|hostStatusProbeNeverSendsStackTokenOnUntrustedRoute)' \ 2 \ 'CmuxMobileRPCTests.MobileCoreRPCClientTests/admittedIrohRequestCarriesNoStackOrAttachCredential()' \ 'CmuxMobileRPCTests.MobileCoreRPCClientTests/hostStatusProbeNeverSendsStackTokenOnUntrustedRoute()' run_package_gate \ - Packages/iOS/CmuxMobileShellModel \ + Packages/Shared/CmuxMobileShellModel \ 'MobileShellRouteAuthPolicyTests/allowsStackAuthOnlyForLoopbackRoutes' \ 1 \ 'CmuxMobileShellModelTests.MobileShellRouteAuthPolicyTests/allowsStackAuthOnlyForLoopbackRoutes()' diff --git a/scripts/lint-ios-package-conventions-baseline.txt b/scripts/lint-ios-package-conventions-baseline.txt index 73099fdcb2c0..d323ef56fe9e 100644 --- a/scripts/lint-ios-package-conventions-baseline.txt +++ b/scripts/lint-ios-package-conventions-baseline.txt @@ -2,27 +2,27 @@ # the PR-diff gate was added. Keep this exact set shrinking; do not add new # branch debt. Format: rulepathfingerprint. lock Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift self.state = OSAllocatedUnfairLock(initialState: State( -lock Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift state = OSAllocatedUnfairLock(initialState: State(capacity: capacity)) -lock Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift private let lock = NSLock() -lock Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/DeviceIdentityStore.swift private static let processLock = OSAllocatedUnfairLock(initialState: ()) +lock Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTraceWriter.swift state = OSAllocatedUnfairLock(initialState: State(capacity: capacity)) +lock Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/RPCTaskTimeoutCancellation.swift private let lock = NSLock() +lock Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/DeviceIdentityStore.swift private static let processLock = OSAllocatedUnfairLock(initialState: ()) lock Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift OSAllocatedUnfairLock(initialState: .init()) free-function Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/CmxDeviceIDCanonicalization.swift public func cmxCanonicalDeviceID(_ deviceID: String) -> String { -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func stableURLIdentity(_ value: String) -> String { -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func defaultPort(for scheme: String) -> Int? { -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func canonicalHostIdentity(_ value: String) -> String { -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func canonicalIPv4Address(_ value: String) -> String? { -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func canonicalIPv6Address(_ value: String) -> String? { -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListIdentityString( -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListString( -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListOptionalString( -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListString(_ value: String, limitedToUTF8Bytes maxBytes -free-function Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift func mobileNotificationFeedListBoundedDecodeOptions( -free-function Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift private func mobileMacConnectionDisplayName( -free-function Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift func warmControlPoolHasCapacity( -free-function Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift func physicalMacAliasCanonicalIDsByCanonicalID( -free-function Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift private func mobileShellNotificationFeedString(_ value: String, limitedToUTF8Bytes maxByte -free-function Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift func secondaryControlAttemptIsTransient(_ error: any Error) -> Bool { -free-function Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift func mobileNotificationFeedItemPrecedes( +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func stableURLIdentity(_ value: String) -> String { +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func defaultPort(for scheme: String) -> Int? { +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func canonicalHostIdentity(_ value: String) -> String { +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func canonicalIPv4Address(_ value: String) -> String? { +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileRPCConnectAttemptKey.swift private func canonicalIPv6Address(_ value: String) -> String? { +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListIdentityString( +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListString( +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListOptionalString( +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedItem.swift private func mobileNotificationFeedListString(_ value: String, limitedToUTF8Bytes maxBytes +free-function Packages/Shared/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileNotificationFeedListBoundedDecodeOptions.swift func mobileNotificationFeedListBoundedDecodeOptions( +free-function Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacConnectionRegistry.swift private func mobileMacConnectionDisplayName( +free-function Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/WarmControlPoolCapacity.swift func warmControlPoolHasCapacity( +free-function Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PairedMacCoalescing.swift func physicalMacAliasCanonicalIDsByCanonicalID( +free-function Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+NotificationFeed.swift private func mobileShellNotificationFeedString(_ value: String, limitedToUTF8Bytes maxByte +free-function Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/SecondaryControlAttemptPolicy.swift func secondaryControlAttemptIsTransient(_ error: any Error) -> Bool { +free-function Packages/Shared/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileNotificationFeedAggregation.swift func mobileNotificationFeedItemPrecedes( free-function Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalOutputApplicationPath.swift func terminalOutputApplicationPath( free-function Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift private func makeNotificationFeedPreviewFixtureItems(referenceDate: Date) -> [MobileNotifi free-function Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/NotificationFeedPreviewView.swift private func makeNotificationFeedPreviewStressItems( @@ -44,9 +44,9 @@ namespace-enum Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticE namespace-enum Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalReplayViewportFence.swift MobileTerminalReplayViewportFence namespace-enum Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileWorkspaceMetadataLimits.swift MobileWorkspaceMetadataLimits namespace-enum Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalScrollbackPreference.swift MobileTerminalScrollbackPreference -namespace-enum Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift MobileLatencyTrace -namespace-enum Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift MobileLatencyProbe -namespace-enum Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift MobileIrohReleaseGateResponseValidator +namespace-enum Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift MobileLatencyTrace +namespace-enum Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileLatencyProbe.swift MobileLatencyProbe +namespace-enum Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShellReleaseGateSupport/MobileIrohReleaseGateResponseValidator.swift MobileIrohReleaseGateResponseValidator namespace-enum Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/HiddenComputerRow.swift HiddenComputersCopy namespace-enum Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/OnboardingScreenshot.swift OnboardingIPhoneScreenshotFrameMetrics namespace-enum Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/ChangesPreviewFixture.swift GeneratedSchema @@ -61,6 +61,6 @@ namespace-type Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTermi namespace-type Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileWorkspaceMetadataLimits.swift MobileWorkspaceMetadataLimits namespace-type Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohTCPFirstActivation.swift CmxIrohTCPFirstActivation namespace-type Packages/Shared/CmuxSentryTelemetry/Sources/CmuxSentryScrubbing/ScrubberDenylists.swift ScrubberDenylists -namespace-type Packages/iOS/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift MobileLatencyTrace +namespace-type Packages/Shared/CmuxMobileDiagnostics/Sources/CmuxMobileDiagnostics/MobileLatencyTrace.swift MobileLatencyTrace namespace-type Packages/iOS/CmuxMobileToast/Sources/CmuxMobileToast/ToastDemo.swift ToastDemo namespace-type Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverMutation.swift CmuxPopoverMutation diff --git a/scripts/lint-ios-package-conventions.sh b/scripts/lint-ios-package-conventions.sh index e0b1c85b5ccc..1df001004f43 100755 --- a/scripts/lint-ios-package-conventions.sh +++ b/scripts/lint-ios-package-conventions.sh @@ -20,7 +20,7 @@ cd "$ROOT" BASELINE_FILE="scripts/lint-ios-package-conventions-baseline.txt" 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 diff --git a/scripts/lint-namespace-types-baseline.txt b/scripts/lint-namespace-types-baseline.txt index cc6af08c7229..44b5ab151d48 100644 --- a/scripts/lint-namespace-types-baseline.txt +++ b/scripts/lint-namespace-types-baseline.txt @@ -27,15 +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/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 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", diff --git a/web/app/api/devices/route.ts b/web/app/api/devices/route.ts index 9be7ebb45799..40228d13cd16 100644 --- a/web/app/api/devices/route.ts +++ b/web/app/api/devices/route.ts @@ -394,6 +394,7 @@ export async function POST(request: Request): Promise { type DeviceListRow = { id: string; deviceUuid: string; + userId: string; platform: string; displayName: string | null; labels: Record; @@ -426,6 +427,7 @@ export async function GET(request: Request): Promise { .select({ id: devices.id, deviceUuid: devices.deviceUuid, + userId: devices.userId, platform: devices.platform, displayName: devices.displayName, labels: devices.labels, @@ -458,6 +460,7 @@ export async function GET(request: Request): Promise { // The phone matches its stored `macDeviceID` (the cmux device UUID) against // this, so expose `deviceUuid`, not the internal surrogate row id. deviceId: device.deviceUuid, + isOwnedByCurrentUser: device.userId === user.id, platform: device.platform, displayName: device.displayName, labels: device.labels, diff --git a/web/bun.lock b/web/bun.lock index 53ee43baae8a..96ca244a5f06 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -81,8 +81,9 @@ "unrs-resolver", ], "patchedDependencies": { - "@stackframe/stack-shared@2.8.108": "patches/@stackframe%2Fstack-shared@2.8.108.patch", "@stackframe/stack@2.8.108": "patches/@stackframe%2Fstack@2.8.108.patch", + "@stackframe/stack-shared@2.8.108": "patches/@stackframe%2Fstack-shared@2.8.108.patch", + "next-themes@0.4.6": "patches/next-themes@0.4.6.patch", }, "overrides": { "fast-xml-builder": "^1.2.0", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index f68c5452ca16..1acfc4481237 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1668,6 +1668,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/data/mobile-mac-compat.ts b/web/data/mobile-mac-compat.ts index 4614181572ff..41d587900cf9 100644 --- a/web/data/mobile-mac-compat.ts +++ b/web/data/mobile-mac-compat.ts @@ -21,7 +21,7 @@ * Devices cache the last fetched list per origin and fall back to a * compiled-in default when they have never fetched. That fallback * (`MobileMacCompatPolicy.baked` in - * Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift) + * Packages/Shared/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacCompatPolicy.swift) * mirrors the initial entries here; keep the two in sync when editing. * Edits to this file are code-reviewed; the route validates it at module * load so a malformed entry fails the build, never the client. diff --git a/web/messages/en.json b/web/messages/en.json index 71588b32fdfc..bab9359251f6 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -2159,6 +2159,9 @@ "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.", "soundOverrides": "Sparse per-agent and per-alert-type notification sound overrides. Missing cells use notifications.sound.", "soundOverride": "A notification sound override. `default` uses notifications.sound, `none` disables playback, and the remaining values select a built-in or custom sound." + }, + "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 1ba51b80b8f8..e72317667944 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -2082,6 +2082,9 @@ "agentIdleReminder": "エージェントがあなたの入力を待って待機状態になったとき(ターン終了の約60秒後)に通知します。前のターンのバックグラウンド作業がまだ実行中の間は抑制されるため、ビルドやウォッチャーの実行中に誤った待機通知が出ることはありません。", "soundOverrides": "エージェントとアラート種別ごとの通知サウンド上書きです。空のセルは notifications.sound を使用します。", "soundOverride": "通知サウンドの上書きです。default は notifications.sound を使用し、none は再生を無効にします。それ以外の値では組み込みまたはカスタムサウンドを選択します。" + }, + "computers": { + "presentation": "ペアリング済みコンピュータの開き方: `windows` は各コンピュータを専用のビューアウインドウで開き、`sidebar` はメインサイドバーのコンピュータピッカーに統合します。" } }, "exampleMarkdownFontSize": "新しく開く Markdown ビューアの本文フォントサイズ(ポイント単位)。", diff --git a/web/package.json b/web/package.json index 648a8e0d38d8..8fe514403843 100644 --- a/web/package.json +++ b/web/package.json @@ -133,6 +133,7 @@ ], "patchedDependencies": { "@stackframe/stack@2.8.108": "patches/@stackframe%2Fstack@2.8.108.patch", - "@stackframe/stack-shared@2.8.108": "patches/@stackframe%2Fstack-shared@2.8.108.patch" + "@stackframe/stack-shared@2.8.108": "patches/@stackframe%2Fstack-shared@2.8.108.patch", + "next-themes@0.4.6": "patches/next-themes@0.4.6.patch" } } 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};