From ca55f5e56cb3e76f79af7544e2ff0e29dfcd597d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 28 Aug 2026 01:45:09 -0700 Subject: [PATCH 01/15] test(ios): cover Tailscale pairing regression --- .../TailscalePairingRegressionTests.swift | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift new file mode 100644 index 000000000000..10f27da7879f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift @@ -0,0 +1,191 @@ +import CMUXMobileCore +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import Testing +@testable import CmuxMobileShell + +/// End-to-end shell coverage for the compatibility Tailscale pairing funnel. +/// +/// These tests intentionally drive the same `connectPairingInput` and +/// `connectManualHost` entry points used by the scanner/paste and Add Computer +/// UI. The scripted host records the transport request and every bearer so a +/// route can be proven to be both selected and authorized before the fix lands. +@MainActor +@Suite struct TailscalePairingRegressionTests { + private let host = "100.71.210.41" + private let port = CmxMobileDefaults.defaultHostPort + + @Test func currentQRCodeEnteredThroughSharedInputAuthorizesExactRoute() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Date.now }, + supportedRouteKinds: [.iroh, .tailscale] + ) + let store = makeStore(runtime: runtime) + store.pairingCode = currentQRCode() + + await store.connectPairingInput() + + #expect(store.connectionState == MobileConnectionState.connected) + #expect(store.activeRoute?.kind == .tailscale) + #expect(factory.attemptedAuthorizationModes() == [ + .userAuthorizedTailscalePairing( + try CmxUserTailscalePairingAuthorization(host: host, port: port) + ), + ]) + let requests = await router.authorization(for: "workspace.list") + #expect(requests.first?.stackAccessToken == "test-stack-token") + } + + @Test func legacyTokenlessQRCodeEnteredThroughPasteUsesTheSameAuthorization() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Date.now }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + // Older Macs encode the same route in the v1 full-key ticket. The + // payload is tokenless, so the explicit in-app paste is the authority. + let ticket = try CmxAttachTicket( + workspaceID: "", + terminalID: nil, + // Legacy pairing URLs may carry no trusted device id; the host + // status response supplies the identity after the authenticated + // route is established. + macDeviceID: "", + macDisplayName: "Legacy Mac", + macPairingCompatibilityVersion: CmxMobileDefaults.pairingCompatibilityVersion, + routes: [try tailscaleRoute()], + expiresAt: Date().addingTimeInterval(3600), + authToken: nil + ) + store.pairingCode = try attachURL(for: ticket) + + await store.connectPairingInput() + + #expect(store.connectionState == MobileConnectionState.connected) + #expect(store.activeRoute?.endpoint == .hostPort(host: host, port: port)) + #expect(factory.attemptedAuthorizationModes() == [ + .userAuthorizedTailscalePairing( + try CmxUserTailscalePairingAuthorization(host: host, port: port) + ), + ]) + } + + @Test func manualNumericEntryAuthorizesExactDestinationAndPersistsRouteGrant() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Date.now }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + await store.connectManualHost(name: "Work Mac", host: host, port: port) + + #expect(store.connectionState == MobileConnectionState.connected) + #expect(store.activeRoute?.kind == .tailscale) + #expect(factory.attemptedAuthorizationModes() == [ + .userAuthorizedTailscalePairing( + try CmxUserTailscalePairingAuthorization(host: host, port: port) + ), + ]) + #expect((await router.authorization(for: "workspace.list")).first?.stackAccessToken == "test-stack-token") + } + + @Test func manualMagicDNSHasDeterministicSafeFallbackWithoutDialing() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Date.now }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + await store.connectManualHost( + name: "Work Mac", + host: "work-mac.tailnet.ts.net", + port: port + ) + + #expect(store.connectionState == MobileConnectionState.disconnected) + #expect(store.activeRoute == nil) + #expect(factory.attemptedAuthorizationModes().isEmpty) + #expect(store.connectionError?.localizedCaseInsensitiveContains("numeric") == true) + #expect(await router.count(of: "workspace.list") == 0) + } + + @Test func arbitraryAndLanManualHostsNeverReceiveAStackBearer() async throws { + for host in ["192.168.1.77", "10.0.0.5", "example.com"] { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Date.now }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + await store.connectManualHost(name: "Untrusted", host: host, port: port) + + #expect(store.connectionState == MobileConnectionState.disconnected) + #expect(factory.attemptedAuthorizationModes().isEmpty) + #expect(await router.authorization(for: "workspace.list").isEmpty) + } + } + + @Test func externallyOpenedQRCodeDoesNotMintInAppTailscaleAuthorization() async throws { + let router = LivenessHostRouter() + let box = TransportBox() + let factory = KindRecordingTransportFactory(router: router, box: box) + let runtime = LivenessTestRuntime( + transportFactory: factory, + now: { Date.now }, + supportedRouteKinds: [.tailscale] + ) + let store = makeStore(runtime: runtime) + + let result = await store.connectPairingURLResult(currentQRCode()) + + #expect(result == .failed) + #expect(factory.attemptedAuthorizationModes().isEmpty) + #expect(await router.authorization(for: "workspace.list").isEmpty) + } + + private func makeStore(runtime: any MobileSyncRuntime) -> MobileShellComposite { + MobileShellComposite( + runtime: runtime, + isSignedIn: true, + identityProvider: StaticIdentityProvider(userID: "phone-user"), + reachability: AlwaysOnlineReachability(), + pairingHintDefaults: UserDefaults( + suiteName: "tailscale-pairing-regression-\(UUID().uuidString)" + )! + ) + } + + private func currentQRCode() -> String { + "cmux-ios://attach?v=2&pc=1&r=\(host):\(port)" + } + + private func tailscaleRoute() throws -> CmxAttachRoute { + try CmxAttachRoute( + id: "tailscale", + kind: .tailscale, + endpoint: .hostPort(host: host, port: port), + priority: 10 + ) + } +} From 28f0dcd94b59772e576c1fe01eb499d5389bc1d7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Fri, 28 Aug 2026 12:38:00 -0700 Subject: [PATCH 02/15] fix(ios): restore secure Tailscale pairing entry paths --- .../MobilePairingFailure.swift | 6 +- ...ileShellComposite+ManualAttachTicket.swift | 11 ++ .../MobileShellComposite.swift | 123 ++++++++++++++++-- .../TailscalePairingRegressionTests.swift | 45 ++++++- .../MobileShellRouteAuthPolicy.swift | 11 +- ...obileAutoConnectMigrationExplanation.swift | 2 +- .../MobilePairingScannerSheet.swift | 12 +- .../CmuxMobileShellUI/PairingView.swift | 8 +- .../SetupHelpGateContent.swift | 4 +- Resources/Localizable.xcstrings | 4 +- .../Mobile/Pairing/MobilePairingView.swift | 2 +- ios/cmux/Resources/Localizable.xcstrings | 118 +++++++++++++---- .../cmuxFeatureTests/cmuxFeatureTests.swift | 67 +++++----- 13 files changed, 313 insertions(+), 100 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift index c65b87036c83..ab21cc941e3f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobilePairingFailure.swift @@ -332,7 +332,7 @@ extension MobilePairingFailureCategory { case .unsupportedRoute: return L10n.string( "mobile.pairing.secureRouteRequired", - defaultValue: "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer." + defaultValue: "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR." ) case .noSupportedRoute: return L10n.string( @@ -373,7 +373,7 @@ extension MobilePairingFailureCategory { case .tailscaleUnavailable: return L10n.string( "mobile.pairing.guidance.tailscaleUnavailable", - defaultValue: "Open Tailscale on both devices, confirm they use the same network, then scan a fresh Pair iPhone code from the Mac." + defaultValue: "Open Tailscale on both devices, then scan a fresh Mac pairing QR or enter its numeric Tailscale IP and port." ) case .hostUnreachable, .dnsFailed, .handshakeTimedOut: return L10n.string( @@ -416,7 +416,7 @@ extension MobilePairingFailureCategory { case .ticketExpired, .unsupportedRoute, .noSupportedRoute: return L10n.string( "mobile.pairing.guidance.rescanFresh", - defaultValue: "On cmux 0.64.17, open Pair iPhone. On newer versions, open Tailscale Pairing. Then scan a fresh QR or link." + defaultValue: "Open Tailscale Pairing on the Mac and scan a fresh QR, or enter the Mac's numeric Tailscale IP and port." ) case .unrecognizedVersion: return L10n.string( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift index 6df1c123e0ef..33bac0e4e87f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ManualAttachTicket.swift @@ -7,6 +7,14 @@ import Foundation @MainActor extension MobileShellComposite { + /// Manual tickets are local placeholders, not authenticated Mac + /// identities. The real device id is learned from the host-status + /// response after the exact route has authenticated. + nonisolated static func isSyntheticManualDeviceID(_ rawValue: String) -> Bool { + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + return value == "manual-ticket-request" || value.hasPrefix("manual-") + } + nonisolated static func boundedPairingRequestTimeoutNanoseconds( runtime: any MobileSyncRuntime, attemptStartedAt: Date @@ -49,6 +57,9 @@ extension MobileShellComposite { ) async throws -> CmxAttachTicket { let directRoute = try Self.manualHostRoute(host: host, port: port) let displayName = name.isEmpty ? host : name + // Non-loopback callers supply an exact user-entry capability to the + // subsequent `connect` call. This helper intentionally mints only a + // route-scoped synthetic ticket and never broadens bearer authority. if MobileShellRouteAuthPolicy.routeAllowsStackAuth(directRoute) { do { let ticket = try await requestManualAttachTicket( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 3673d658357d..0b3f7d3e0738 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2561,18 +2561,87 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { return } - let directRoute = try? Self.manualHostRoute( + guard let directRoute = try? Self.manualHostRoute( host: normalizedHost, port: port - ) - let sameRouteProbeClient: MobileCoreRPCClient? = directRoute.flatMap { route in + ) else { + if recordsPairingAttempt { + recordAppEvent(.pairingStarted) + recordAppEvent(.pairingFailed, failure: .protocolViolation) + } + connectionError = L10n.string( + "mobile.addDevice.invalidHost", + defaultValue: "Enter a host or IP address, without spaces or URL paths." + ) + connectionErrorGuidance = nil + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + return + } + + let isLoopbackRoute = MobileShellRouteAuthPolicy.routeIsLoopback(directRoute) + if MobileShellRouteAuthPolicy.ticketRejectsLoopbackRoutes( + [directRoute], + isPhysicalDevice: Self.isPhysicalDevice + ) { + if recordsPairingAttempt { + recordAppEvent(.pairingStarted) + recordAppEvent(.pairingFailed, failure: .unsupportedRoute) + } + connectionError = L10n.string( + "mobile.pairing.loopbackRejected", + defaultValue: "This device cannot connect to the Mac through localhost. Scan the Mac's Tailscale pairing QR or enter its numeric Tailscale IP." + ) + connectionErrorGuidance = nil + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + return + } + + // A fresh manual attempt is an explicit user action, so a numeric + // Tailscale address can receive the same exact-destination capability + // as a scanned/pasted QR route. MagicDNS, LAN, and arbitrary names do + // not provide a stable peer proof and must fail before any TCP dial. + let userTailscalePairingAuthorization: CmxUserTailscalePairingAuthorization? + if recordsPairingAttempt && !isLoopbackRoute { + userTailscalePairingAuthorization = try? CmxUserTailscalePairingAuthorization( + host: normalizedHost, + port: port + ) + guard userTailscalePairingAuthorization != nil else { + recordAppEvent(.pairingStarted) + recordAppEvent(.pairingFailed, failure: .unsupportedRoute) + connectionError = L10n.string( + "mobile.addDevice.tailscaleNumericRequired", + defaultValue: "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." + ) + connectionErrorGuidance = nil + connectionState = .disconnected + macConnectionStatus = .unavailable + clearRemoteConnectionContext() + analytics.capture("ios_pairing_failed", [ + "method": .string("manual"), + "reason": .string("unsupported_route"), + "failure_phase": .string("validation"), + "is_first_pair": .bool(!hasKnownPairedMac), + ]) + return + } + } else { + userTailscalePairingAuthorization = nil + } + + let sameRouteProbeClient: MobileCoreRPCClient? = { + let route = directRoute guard remoteClient?.sharesPhysicalTransportRoute( with: route ) == true else { return nil } return remoteClient - } + }() if sameRouteProbeClient == nil { activeRoute = directRoute } @@ -2590,7 +2659,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } // Fast offline preflight: fail immediately instead of stacking // per-route timeouts into the opaque ~60s blob. - let manualRoutes = directRoute.map { [$0] } ?? [] + let manualRoutes = [directRoute] if sameRouteProbeClient == nil { guard await failPairingIfOffline( attemptID: attemptID, @@ -2618,7 +2687,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } let noThrowFailure = try await connect( ticket: ticket, - allowsStackAuthFallback: true, + // The generic Stack-bearer fallback remains loopback-only. + // Numeric Tailscale pairing uses the explicit authorization + // mode below, which is independent of this fallback flag. + allowsStackAuthFallback: isLoopbackRoute, + userTailscalePairingAuthorizations: userTailscalePairingAuthorization.map { [$0] } ?? [], pairedMacDeviceID: pairedMacDeviceID, instanceTagExpectation: instanceTagExpectation, ifStillCurrent: ifStillCurrent @@ -9231,7 +9304,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { let ticketMacDeviceID = ticket.macDeviceID .trimmingCharacters(in: .whitespacesAndNewlines) let requestedMacDeviceID = pairedMacDeviceID - ?? (ticketMacDeviceID.isEmpty ? nil : ticketMacDeviceID) + ?? (ticketMacDeviceID.isEmpty + || Self.isSyntheticManualDeviceID(ticketMacDeviceID) + ? nil + : ticketMacDeviceID) let previousForegroundKeyBeforeConnect = foregroundOrRecoveryMacKey let currentFocusedConnection: MacConnection? = foregroundMacDeviceID.flatMap { macID in @@ -9260,10 +9336,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // pins no addresses (automatic, or a legacy pairing without an Iroh // identity). let directOnlyDialCandidates = directOnlyDialCandidates - ?? irohMethodPinnedDialCandidates( - forMacDeviceID: requestedMacDeviceID ?? ticket.macDeviceID, - instanceTag: instanceTagExpectation.expectedTag - ) + ?? (userTailscalePairingAuthorizations.isEmpty + ? irohMethodPinnedDialCandidates( + forMacDeviceID: requestedMacDeviceID ?? ticket.macDeviceID, + instanceTag: instanceTagExpectation.expectedTag + ) + : nil) let supportedRoutes = supportedRoutes( for: ticket, supportedKinds: supportedKinds, @@ -9652,7 +9730,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { } let ticketDeviceID = ticket.macDeviceID .trimmingCharacters(in: .whitespacesAndNewlines) - let expectedDeviceID = pairedMacDeviceID ?? (ticketDeviceID.isEmpty ? nil : ticketDeviceID) + let expectedDeviceID = pairedMacDeviceID + ?? (ticketDeviceID.isEmpty + || Self.isSyntheticManualDeviceID(ticketDeviceID) + ? nil + : ticketDeviceID) if await adoptWouldConflictWithStoredInstanceAuthority( expectation: instanceTagExpectation, reportedInstanceTag: reportedInstanceTag, @@ -10020,6 +10102,20 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { supportedKinds.contains(route.kind) } } + // An explicit QR/manual entry is itself the authorization event. Keep + // the dial on the exact numeric Tailscale destination it named even + // when the app-wide method is Automatic, Iroh, or Tailscale Only. + // `directOnly` is reserved for an already-paired Direct connection and + // must remain the stronger, Iroh-only constraint. + if !directOnly, !userTailscalePairingAuthorizations.isEmpty { + return supportedRoutes.filter { route in + Self.userTailscalePairingAuthorization( + for: route, + authorizations: userTailscalePairingAuthorizations + ) != nil + } + } + // The explicit Tailscale method is strict: only authorized Tailscale // destinations may be dialed, and an unavailable route leaves the app // disconnected instead of silently switching to Iroh. The method is @@ -10135,7 +10231,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { _ ticket: CmxAttachTicket, adoptingReportedDeviceID reportedDeviceID: String? ) -> CmxAttachTicket { - guard ticket.macDeviceID.isEmpty, + guard (ticket.macDeviceID.isEmpty + || Self.isSyntheticManualDeviceID(ticket.macDeviceID)), let reportedDeviceID = reportedDeviceID? .trimmingCharacters(in: .whitespacesAndNewlines), !reportedDeviceID.isEmpty, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift index 10f27da7879f..b9ee3e7a98b1 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TailscalePairingRegressionTests.swift @@ -1,4 +1,5 @@ import CMUXMobileCore +import CmuxMobilePairedMac import CmuxMobileRPC import CmuxMobileShellModel import Foundation @@ -16,7 +17,10 @@ import Testing private let host = "100.71.210.41" private let port = CmxMobileDefaults.defaultHostPort - @Test func currentQRCodeEnteredThroughSharedInputAuthorizesExactRoute() async throws { + @Test(arguments: MobileConnectionMethod.allCases) + func currentQRCodeEnteredThroughSharedInputAuthorizesExactRoute( + _ method: MobileConnectionMethod + ) async throws { let router = LivenessHostRouter() let box = TransportBox() let factory = KindRecordingTransportFactory(router: router, box: box) @@ -25,7 +29,7 @@ import Testing now: { Date.now }, supportedRouteKinds: [.iroh, .tailscale] ) - let store = makeStore(runtime: runtime) + let store = makeStore(runtime: runtime, connectionMethod: method) store.pairingCode = currentQRCode() await store.connectPairingInput() @@ -79,16 +83,27 @@ import Testing ]) } - @Test func manualNumericEntryAuthorizesExactDestinationAndPersistsRouteGrant() async throws { + @Test func manualNumericEntryAuthorizesExactDestination() async throws { let router = LivenessHostRouter() let box = TransportBox() let factory = KindRecordingTransportFactory(router: router, box: box) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let pairedMacStore = try MobilePairedMacStore( + databaseURL: directory.appendingPathComponent("paired-macs.sqlite3") + ) let runtime = LivenessTestRuntime( transportFactory: factory, now: { Date.now }, - supportedRouteKinds: [.tailscale] + supportedRouteKinds: [.tailscale], + supportsServerPushEvents: false ) - let store = makeStore(runtime: runtime) + let store = makeStore(runtime: runtime, pairedMacStore: pairedMacStore) await store.connectManualHost(name: "Work Mac", host: host, port: port) @@ -100,6 +115,8 @@ import Testing ), ]) #expect((await router.authorization(for: "workspace.list")).first?.stackAccessToken == "test-stack-token") + let saved = try await pairedMacStore.activeMac(stackUserID: "phone-user") + #expect(saved?.legacyTailscaleRoutes?.first?.endpoint == .hostPort(host: host, port: port)) } @Test func manualMagicDNSHasDeterministicSafeFallbackWithoutDialing() async throws { @@ -164,10 +181,24 @@ import Testing #expect(await router.authorization(for: "workspace.list").isEmpty) } - private func makeStore(runtime: any MobileSyncRuntime) -> MobileShellComposite { - MobileShellComposite( + private func makeStore( + runtime: any MobileSyncRuntime, + pairedMacStore: (any MobilePairedMacStoring)? = nil, + connectionMethod: MobileConnectionMethod? = nil + ) -> MobileShellComposite { + let methodStore: MobileConnectionMethodStore? = connectionMethod.map { method in + let defaults = UserDefaults( + suiteName: "tailscale-pairing-regression-method-\(UUID().uuidString)" + )! + let store = MobileConnectionMethodStore(defaults: defaults) + store.method = method + return store + } + return MobileShellComposite( runtime: runtime, isSignedIn: true, + pairedMacStore: pairedMacStore, + connectionMethodStore: methodStore, identityProvider: StaticIdentityProvider(userID: "phone-user"), reachability: AlwaysOnlineReachability(), pairingHintDefaults: UserDefaults( diff --git a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift index 614a36cc328e..ad3a3b2f1b9e 100644 --- a/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift +++ b/Packages/iOS/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileShellRouteAuthPolicy.swift @@ -53,9 +53,12 @@ public struct MobileShellRouteAuthPolicy { return host } - /// Maps a manually typed host to the transport kind that should be used. + /// Maps a manually typed host to the transport kind used for route checks. /// - Parameter host: The host to classify. /// - Returns: `.debugLoopback` for loopback hosts, otherwise `.tailscale`. + /// + /// Non-loopback classification is not authorization: callers must still + /// attach an exact numeric Tailscale pairing capability before dialing. public static func manualRouteKind(for host: String) -> CmxAttachTransportKind { let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if isLoopbackHost(normalizedHost) { @@ -140,9 +143,11 @@ public struct MobileShellRouteAuthPolicy { return isLoopbackHost(host) } - /// Whether a manual host should warn that it cannot carry account credentials. + /// Whether a manual host should show the explicit non-loopback trust guidance. /// - Parameter host: The manually typed host. - /// - Returns: `true` for every valid host outside loopback. + /// - Returns: `true` for every valid host outside loopback, where a + /// numeric Tailscale capability (or another supported secure path) is + /// required before account credentials may be sent. public static func manualHostNeedsTrustWarning(_ host: String) -> Bool { guard let normalizedHost = normalizedManualNetworkHost(host) else { return false diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift index e114bc88c1b1..47b102dd9249 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileAutoConnectMigrationExplanation.swift @@ -59,7 +59,7 @@ struct MobileAutoConnectMigrationExplanation: View { private var guidance: some View { Text(L10n.string( "mobile.autoConnectMigration.guidance", - defaultValue: "cmux 0.64.17 still works over Tailscale. Choose Connect iPhone/iPad on that Mac and scan its Pair iPhone code once." + defaultValue: "Older cmux versions still work over Tailscale. Open Tailscale Pairing on the Mac and scan its QR, or enter its numeric Tailscale IP and port once." )) .font(.body) .foregroundStyle(.secondary) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift index 31147cf47350..e54750f92afc 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePairingScannerSheet.swift @@ -222,9 +222,8 @@ extension MobilePairingScannerSheet { L10n.string( "mobile.tailscalePairing.instructions", defaultValue: """ - Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, \ - choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open \ - Tailscale Pairing and scan its code here. + Install Tailscale on both devices and use the same Tailscale network. Open Tailscale \ + Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port. """ ) } @@ -234,10 +233,9 @@ extension MobilePairingScannerSheet { L10n.string( "mobile.tailscalePairing.emptyDescription", defaultValue: """ - Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, \ - choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open \ - Tailscale Pairing and scan its code here. To use Auto-Connect instead, open Settings, \ - tap Connection Method, and choose Auto-Connect. + Install Tailscale on both devices and use the same Tailscale network. Open Tailscale \ + Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port. \ + To use Auto-Connect instead, open Settings, tap Connection Method, and choose Auto-Connect. """ ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift index 6d6030989a54..8bac0eb24da8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/PairingView.swift @@ -79,7 +79,7 @@ struct PairingView: View { .accessibilityIdentifier("MobileAddDeviceNameField") TextField( - L10n.string("mobile.addDevice.hostPlaceholder", defaultValue: "127.0.0.1 (simulator only)"), + L10n.string("mobile.addDevice.hostPlaceholder", defaultValue: "100.x.x.x (Tailscale IP; 127.0.0.1 in Simulator)"), text: $host ) .focused($focusedField, equals: .host) @@ -102,7 +102,7 @@ struct PairingView: View { Text(MobilePairingScannerSheet.guidanceText) Text(L10n.string( "mobile.addDevice.help", - defaultValue: "Manual host and port entry is an advanced fallback for reconnecting an already paired Mac." + defaultValue: "Scan the Mac's pairing QR, or enter its numeric Tailscale IP and port. In the Simulator, 127.0.0.1 can connect to a local Mac. MagicDNS names and local or LAN hosts aren't supported for account-authenticated pairing." )) } } @@ -136,7 +136,7 @@ struct PairingView: View { .textSelection(.enabled) .accessibilityIdentifier("MobileAddDeviceSignedInAccount") - Text(L10n.string("mobile.addDevice.accountHelp", defaultValue: "Manual pairing uses this account. If it does not match the Mac, scan a QR/link from the Mac.")) + Text(L10n.string("mobile.addDevice.accountHelp", defaultValue: "Pairing uses this account. If it does not match the Mac, sign in to the same account, then scan the Mac QR or enter its numeric Tailscale IP.")) .font(.footnote) .foregroundStyle(.secondary) } @@ -376,7 +376,7 @@ struct PairingView: View { } return L10n.string( "mobile.addDevice.manualRouteWarning", - defaultValue: "Manual credentials work only in the simulator. On a device, choose Tailscale and scan the Mac QR." + defaultValue: "For account-authenticated pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift index aa6112bf4d4b..1a5f4b577dff 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/SetupHelpGateContent.swift @@ -40,8 +40,8 @@ struct SetupHelpGateContent { The computer then appears on this phone automatically. \ To pair through Tailscale, install Tailscale on both devices \ and connect them to the same Tailscale network. \ - On cmux 0.64.17, choose Connect iPhone/iPad and scan its Pair iPhone code. \ - On newer versions, open Tailscale Pairing and scan its code here. + Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's \ + numeric Tailscale IP and port. """ ), link: nil, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 5a1f282cbbee..326fe1f6664d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -145118,13 +145118,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Can't scan? Add this Mac manually:" + "value": "Can't scan? Enter this Mac's numeric Tailscale IP and port:" } }, "ja": { "stringUnit": { "state": "translated", - "value": "スキャンできない場合は、この Mac を手動で追加します:" + "value": "スキャンできない場合は、この Mac の数値 Tailscale IP アドレスとポートを入力してください:" } } } diff --git a/Sources/Mobile/Pairing/MobilePairingView.swift b/Sources/Mobile/Pairing/MobilePairingView.swift index 2ef757a26fe4..b70ac97b1571 100644 --- a/Sources/Mobile/Pairing/MobilePairingView.swift +++ b/Sources/Mobile/Pairing/MobilePairingView.swift @@ -466,7 +466,7 @@ struct MobilePairingView: View { @ViewBuilder private func manualEntry(_ ready: MobilePairingModel.Ready) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(String(localized: "mobile.pairing.manual.title", defaultValue: "Can't scan? Add this Mac manually:")) + Text(String(localized: "mobile.pairing.manual.title", defaultValue: "Can't scan? Enter this Mac's numeric Tailscale IP and port:")) .cmuxFont(.caption, weight: .semibold) .foregroundStyle(.secondary) ForEach(ready.tailscaleLines, id: \.self) { line in diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 4ebfb5c2af4f..ad048ac58642 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -466,13 +466,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Manual pairing uses this account. If it does not match the Mac, scan a QR/link from the Mac." + "value": "Pairing uses this account. If it does not match the Mac, sign in to the same account, then scan the Mac QR or enter its numeric Tailscale IP." } }, "ja": { "stringUnit": { "state": "translated", - "value": "手動ペアリングではこのアカウントを使用します。Mac と一致しない場合は、Mac から QR/リンクをスキャンしてください。" + "value": "ペアリングではこのアカウントを使用します。Mac と一致しない場合は、同じアカウントでサインインしてから、Mac の QR をスキャンするか数値の Tailscale IP を入力してください。" } } } @@ -500,13 +500,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Manual host and port entry is an advanced fallback for reconnecting an already paired Mac." + "value": "Scan the Mac's pairing QR, or enter its numeric Tailscale IP and port. In the Simulator, 127.0.0.1 can connect to a local Mac. MagicDNS names and local or LAN hosts aren't supported for account-authenticated pairing." } }, "ja": { "stringUnit": { "state": "translated", - "value": "ホストとポートの手入力は、ペアリング済みのMacへ再接続するための高度な代替手段です。" + "value": "Mac のペアリング QR をスキャンするか、数値の Tailscale IP アドレスとポートを入力してください。シミュレータでは 127.0.0.1 で Mac に接続できます。アカウント認証によるペアリングでは、MagicDNS 名やローカル/LAN ホストはサポートされません。" } } } @@ -534,13 +534,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "127.0.0.1 (simulator only)" + "value": "100.x.x.x (Tailscale IP; 127.0.0.1 in Simulator)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "127.0.0.1(シミュレータのみ)" + "value": "100.x.x.x(Tailscale IP、シミュレータでは127.0.0.1)" } } } @@ -579,19 +579,36 @@ } } }, + "mobile.addDevice.tailscaleNumericRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Tailscale でペアリングするには、Mac の数値 Tailscale IP アドレスを入力するか QR をスキャンしてください。MagicDNS 名やローカル/LAN ホストはサポートされません。" + } + } + } + }, "mobile.addDevice.manualRouteWarning": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Manual credentials work only in the simulator. On a device, choose Tailscale and scan the Mac QR." + "value": "For account-authenticated pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported." } }, "ja": { "stringUnit": { "state": "translated", - "value": "アカウント認証情報はシミュレータのループバックでのみ送信できます。実機では Tailscale を選択し、Mac の QR をスキャンしてください。" + "value": "アカウント認証によるペアリングでは、Mac の数値 Tailscale IP アドレスを入力するか QR をスキャンしてください。MagicDNS 名やローカル/LAN ホストはサポートされません。" } } } @@ -5107,13 +5124,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "cmux 0.64.17 still works over Tailscale. Choose Connect iPhone/iPad on that Mac and scan its Pair iPhone code once." + "value": "Older cmux versions still work over Tailscale. Open Tailscale Pairing on the Mac and scan its QR, or enter its numeric Tailscale IP and port once." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux 0.64.17でもTailscale経由で接続できます。Macで「Connect iPhone/iPad」を選び、「Pair iPhone」のコードを一度スキャンしてください。" + "value": "古い cmux バージョンでも Tailscale 経由で接続できます。Mac で「Tailscale Pairing」を開き、QR をスキャンするか、数値の Tailscale IP アドレスとポートを一度入力してください。" } } } @@ -6433,13 +6450,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "On cmux 0.64.17, open Pair iPhone. On newer versions, open Tailscale Pairing. Then scan a fresh QR or link." + "value": "Open Tailscale Pairing on the Mac and scan a fresh QR, or enter the Mac's numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "cmux 0.64.17では「Pair iPhone」を開きます。新しいバージョンでは「Tailscale Pairing」を開き、新しいQRコードまたはリンクをスキャンしてください。" + "value": "Mac で「Tailscale Pairing」を開き、新しい QR をスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力してください。" } } } @@ -6450,13 +6467,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Open Tailscale on both devices, confirm they use the same network, then scan a fresh Pair iPhone code from the Mac." + "value": "Open Tailscale on both devices, then scan a fresh Mac pairing QR or enter its numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方のデバイスでTailscaleを開き、同じネットワークを使用していることを確認してから、Macの新しい「Pair iPhone」コードをスキャンしてください。" + "value": "両方のデバイスで Tailscale を開き、新しい Mac のペアリング QR をスキャンするか、数値の Tailscale IP アドレスとポートを入力してください。" } } } @@ -6580,6 +6597,57 @@ } } }, + "mobile.pairing.manual.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Can't scan? Enter this Mac's numeric Tailscale IP and port:" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキャンできない場合は、この Mac の数値 Tailscale IP アドレスとポートを入力してください:" + } + } + } + }, + "mobile.pairing.manual.copyIP": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy IP" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "IP をコピー" + } + } + } + }, + "mobile.pairing.manual.copyPort": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Port" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポートをコピー" + } + } + } + }, "mobile.pairing.navigationTitle": { "extractionState": "manual", "localizations": { @@ -6722,13 +6790,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "This pairing route is not trusted. Pair again with a fresh QR/link from that computer." + "value": "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR." } }, "ja": { "stringUnit": { "state": "translated", - "value": "このペアリング経路は信頼できません。そのコンピュータの新しい QR/リンクで再度ペアリングしてください。" + "value": "このペアリング経路は信頼できません。Mac の数値 Tailscale IP アドレスとポートを入力するか、ペアリング QR をスキャンしてください。" } } } @@ -8745,13 +8813,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Install cmux on your computer, sign in to the same account, and leave it running. The computer then appears on this phone automatically. To pair through Tailscale, install Tailscale on both devices and connect them to the same Tailscale network. On cmux 0.64.17, choose Connect iPhone/iPad and scan its Pair iPhone code. On newer versions, open Tailscale Pairing and scan its code here." + "value": "Install cmux on your computer, sign in to the same account, and leave it running. The computer then appears on this phone automatically. To pair through Tailscale, install Tailscale on both devices and connect them to the same Tailscale network. Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "コンピュータにcmuxをインストールし、同じcmuxアカウントでサインインしたまま起動しておきます。コンピュータは自動的にこの端末に表示されます。Tailscale経由でペアリングするには、両方のデバイスにTailscaleをインストールし、同じTailscaleネットワークに接続してください。cmux 0.64.17では「Connect iPhone/iPad」を選び、「Pair iPhone」のコードをスキャンします。新しいバージョンでは「Tailscale Pairing」を開き、表示されたコードをここでスキャンします。" + "value": "コンピュータに cmux をインストールし、同じ cmux アカウントでサインインしたまま起動しておきます。コンピュータは自動的にこの端末に表示されます。Tailscale 経由でペアリングするには、両方のデバイスに Tailscale をインストールし、同じ Tailscale ネットワークに接続してください。Mac で「Tailscale Pairing」を開き、QR をここでスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力します。" } } } @@ -9612,13 +9680,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "QR pairing usually needs both devices on the same Tailscale network. Turn Tailscale on first, or pair by host and port on a trusted local network." + "value": "Keep both devices on the same Tailscale network. Scan the Mac's pairing QR, or enter its numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "QR ペアリングには通常、両方のデバイスが同じ Tailscale ネットワークに接続されている必要があります。まず Tailscale をオンにするか、信頼できるローカルネットワーク上でホストとポートを指定してペアリングしてください。" + "value": "両方のデバイスを同じ Tailscale ネットワークに接続してください。Mac のペアリング QR をスキャンするか、数値の Tailscale IP アドレスとポートを入力します。" } } } @@ -20407,13 +20475,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open Tailscale Pairing and scan its code here. To use Iroh instead, open Settings, tap Connection Method, and choose Iroh." + "value": "Install Tailscale on both devices and use the same Tailscale network. Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port. To use Iroh instead, open Settings, tap Connection Method, and choose Iroh." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方のデバイスにTailscaleをインストールし、同じTailscaleネットワークを使用してください。cmux 0.64.17では「Connect iPhone/iPad」を選び、「Pair iPhone」のコードをスキャンします。新しいバージョンでは「Tailscale Pairing」を開き、表示されたコードをここでスキャンします。代わりにIrohを使うには、「設定」を開き、「接続方法」をタップして「Iroh」を選択してください。" + "value": "両方のデバイスに Tailscale をインストールし、同じ Tailscale ネットワークを使用してください。Mac で「Tailscale Pairing」を開き、QR をここでスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力します。Iroh を使うには、「設定」で「接続方法」を開き、「Iroh」を選択してください。" } } } @@ -20424,13 +20492,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Install Tailscale on both devices and use the same Tailscale network. On cmux 0.64.17, choose Connect iPhone/iPad and scan the Pair iPhone code. On newer versions, open Tailscale Pairing and scan its code here." + "value": "Install Tailscale on both devices and use the same Tailscale network. Open Tailscale Pairing on the Mac and scan its QR here, or enter the Mac's numeric Tailscale IP and port." } }, "ja": { "stringUnit": { "state": "translated", - "value": "両方のデバイスにTailscaleをインストールし、同じTailscaleネットワークを使用してください。cmux 0.64.17では「Connect iPhone/iPad」を選び、「Pair iPhone」のコードをスキャンします。新しいバージョンでは「Tailscale Pairing」を開き、表示されたコードをここでスキャンします。" + "value": "両方のデバイスに Tailscale をインストールし、同じ Tailscale ネットワークを使用してください。Mac で「Tailscale Pairing」を開き、QR をここでスキャンするか、Mac の数値 Tailscale IP アドレスとポートを入力してください。" } } } @@ -23684,4 +23752,4 @@ } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 81397d0f9ba2..785fc66649d5 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -634,7 +634,7 @@ final class TerminalOutputCollector { } @MainActor -@Test func manualHostPairingRejectsTailscaleMagicDNSWithoutSendingAuth() async throws { +@Test func manualHostPairingRejectsTailscaleMagicDNSWithNumericGuidance() async throws { let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], @@ -648,6 +648,7 @@ final class TerminalOutputCollector { #expect(store.phase == .pairing) #expect(store.connectionState == .disconnected) #expect(store.activeRoute == nil) + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } @@ -671,7 +672,7 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } @@ -695,51 +696,51 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } @MainActor -@Test func manualHostPairingRejectsTailscaleBeforeLegacyProbeOrFallback() async throws { - let responses = ScriptedTransportResponses([]) +@Test func manualHostPairingAuthorizesExactNumericTailscaleDestination() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "manual-workspace", title: "Work Workspace"), + try rpcHostStatusFrame(renderGrid: false), + ]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), - stackAccessToken: "stack-token-for-fallback" + stackAccessToken: "test-stack-token" ) let store = CMUXMobileShellStore.preview(runtime: runtime) store.signIn() await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: 15432) - #expect(store.phase == .pairing) - #expect(store.connectionState == .disconnected) - #expect(store.activeRoute == nil) - #expect(try await responses.sentRequests().isEmpty) + #expect(store.phase == .workspaces) + #expect(store.connectionState == .connected) + #expect(store.activeRoute?.kind == .tailscale) + let requests = try await responses.sentRequests() + #expect(requests.first?.method == "workspace.list") + #expect(requests.first?.stackAccessToken == "test-stack-token") } @MainActor -@Test func manualHostPairingRejectsTailscaleWithFreshPairingGuidance() async throws { - let route = try CmxAttachRoute( - id: "tailscale", - kind: .tailscale, - endpoint: .hostPort(host: "work-mac.tailnet.ts.net", port: CmxMobileDefaults.defaultHostPort) - ) +@Test func manualHostPairingRejectsMagicDNSBeforeDialing() async throws { + let responses = ScriptedTransportResponses([]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], - transportFactory: HangingTransportFactory(), - pairingRequestTimeoutNanoseconds: 1_000_000 + transportFactory: ScriptedTransportFactory(responses: responses) ) let store = CMUXMobileShellStore.preview(runtime: runtime) store.signIn() await store.connectManualHost(name: "Slow Mac", host: "work-mac.tailnet.ts.net", port: CmxMobileDefaults.defaultHostPort) - #expect(route.kind == .tailscale) #expect(store.phase == .pairing) #expect(store.connectionState == .disconnected) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") - #expect(store.connectionErrorGuidance == "Open the pairing window on your Mac and scan a fresh QR or link.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") + #expect(store.connectionErrorGuidance == nil) + #expect(try await responses.sentRequests().isEmpty) } @MainActor @@ -759,7 +760,7 @@ final class TerminalOutputCollector { ) store.signIn() - await store.connectManualHost(name: "Work Mac", host: "work-mac.tailnet.ts.net", port: CmxMobileDefaults.defaultHostPort) + await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: CmxMobileDefaults.defaultHostPort) #expect(store.phase == .pairing) #expect(store.connectionState == .disconnected) @@ -1004,7 +1005,7 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "This pairing route is not trusted. Enter the Mac's numeric Tailscale IP and port, or scan its pairing QR.") #expect(try await responses.sentRequests().isEmpty) } @@ -1836,8 +1837,11 @@ final class TerminalOutputCollector { } @MainActor -@Test func manualHostPairingRejectsTailscaleIPWithoutSendingStackToken() async throws { - let responses = ScriptedTransportResponses([]) +@Test func manualHostPairingNumericTailscaleSendsBearerOnlyAfterExactAuthorization() async throws { + let responses = ScriptedTransportResponses([ + try rpcWorkspaceListFrame(workspaceID: "manual-workspace", title: "Work Workspace"), + try rpcHostStatusFrame(renderGrid: false), + ]) let runtime = testRuntime( supportedRouteKinds: [.tailscale], transportFactory: ScriptedTransportFactory(responses: responses), @@ -1848,12 +1852,11 @@ final class TerminalOutputCollector { store.signIn() await store.connectManualHost(name: "Work Mac", host: "100.71.210.41", port: CmxMobileDefaults.defaultHostPort) - #expect(store.phase == .pairing) - #expect(store.connectionState == .disconnected) - #expect(store.activeTicket == nil) - #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") - #expect(try await responses.sentRequests().isEmpty) + #expect(store.phase == .workspaces) + #expect(store.connectionState == .connected) + #expect(store.activeRoute?.kind == .tailscale) + let requests = try await responses.sentRequests() + #expect(requests.first?.stackAccessToken == "stack-token-for-tailscale-ip") } @MainActor @@ -1876,7 +1879,7 @@ final class TerminalOutputCollector { #expect(store.connectionState == .disconnected) #expect(store.activeTicket == nil) #expect(store.activeRoute == nil) - #expect(store.connectionError == "This pairing route is not allowed. Enter a host and port, or pair with a QR/link from that computer.") + #expect(store.connectionError == "For Tailscale pairing, enter the Mac's numeric Tailscale IP or scan its QR. MagicDNS names and local or LAN hosts aren't supported.") #expect(try await responses.sentRequests().isEmpty) } From c60f004185a08c312870598fdd2fd5de5773ebcc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 17:46:49 -0700 Subject: [PATCH 03/15] fix(build): bind Git config byte count under Swift 6 --- .../Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift index da23b16d6732..f54273a233a7 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift @@ -46,7 +46,7 @@ nonisolated struct GitConfigTraversalBudget: Sendable { maximumByteCount: min(remainingByteCount, maximumFileByteCount), deadline: deadline ) { - case .contents(let contents, consumedByteCount: byteCount): + case .contents(let contents, consumedByteCount: let byteCount): remainingByteCount = max(0, remainingByteCount - byteCount) return contents case .oversized(let byteCount): From b1c0c0ead1b5523fece651b88c55c5b6bebd2b2f Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 17:54:35 -0700 Subject: [PATCH 04/15] fix(build): type continuation result explicitly --- .../CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift index e033c1390855..a0efe123b9fd 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift @@ -18,10 +18,10 @@ extension GitMetadataService { } guard didAcquire else { return nil } let cancellationSignal = WorkspaceChangesCancellationSignal(deadline: effectiveDeadline) - let output = await withTaskCancellationHandler { - await withCheckedContinuation { continuation in + let output: String? = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in Self.blockingStatusQueue.async { - let output = cancellationSignal.withCurrentBinding { + let output: String? = cancellationSignal.withCurrentBinding { let selector = GitReferenceRunnerSelector(wallTimeLimit: wallTimeLimit) let deadline = effectiveDeadline for runner in selector.candidateRunners { From 9f6682d4410645d25bb9910d2f4624561133e3ad Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:04:43 -0700 Subject: [PATCH 05/15] fix(build): call Darwin stat through C string --- .../Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift index 9022856237cc..844ead7804a3 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift @@ -10,7 +10,7 @@ nonisolated protocol GitReferenceStorageProbing: Sendable { nonisolated struct SystemGitReferenceStorageProbe: GitReferenceStorageProbing { func isDirectory(atPath path: String) -> Bool { var metadata = stat() - return Darwin.stat(path, &metadata) == 0 + return path.withCString { Darwin.stat($0, &metadata) == 0 } && metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) } } From 01880bb864854292d96b019444dcd531d37b8da6 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:15:01 -0700 Subject: [PATCH 06/15] fix(build): disambiguate Darwin stat calls --- .../Sources/CmuxGit/Changes/GitExecutableFileProbing.swift | 2 +- .../CmuxGit/Parsing/GitMetadataService+WatchFallback.swift | 2 +- .../Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift index 0e04ba579084..57d341e04e01 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift @@ -10,7 +10,7 @@ nonisolated protocol GitExecutableFileProbing: Sendable { nonisolated struct SystemGitExecutableFileProbe: GitExecutableFileProbing { func isExecutableFile(atPath path: String) -> Bool { var metadata = stat() - return Darwin.stat(path, &metadata) == 0 + return path.withCString { stat($0, &metadata) == 0 } && metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) && Darwin.access(path, X_OK) == 0 } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift index 186c8d5f3ab0..d13ea1765d63 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift @@ -37,7 +37,7 @@ extension GitMetadataService { private nonisolated func isDirectory(atPath path: String) -> Bool { var metadata = stat() - return path.withCString { Darwin.stat($0, &metadata) == 0 } + return path.withCString { stat($0, &metadata) == 0 } && metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) } } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift index 844ead7804a3..4bca68356753 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift @@ -10,7 +10,7 @@ nonisolated protocol GitReferenceStorageProbing: Sendable { nonisolated struct SystemGitReferenceStorageProbe: GitReferenceStorageProbing { func isDirectory(atPath path: String) -> Bool { var metadata = stat() - return path.withCString { Darwin.stat($0, &metadata) == 0 } + return path.withCString { stat($0, &metadata) == 0 } && metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) } } From a0bbea9d324e72bf3c05166b1c953cb667b1d592 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:20:37 -0700 Subject: [PATCH 07/15] fix(build): avoid Swift 6 git metadata redeclarations --- .../macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift | 4 ++-- .../CmuxGit/Parsing/GitMetadataService+WatchFallback.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift index d45925de2f4d..bbc374a47dfb 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/GitMetadataService.swift @@ -118,12 +118,12 @@ public struct GitMetadataService: Sendable { guard let repository = Self.resolveGitRepository(containing: directory) else { return .notARepository } - async let initialReferences = gitReferenceSnapshot(repository: repository) + async let initialReferencesTask = gitReferenceSnapshot(repository: repository) async let initialTrackedChanges = gitTrackedChangesSnapshot( repository: repository, trackedPathEventGeneration: trackedPathEventGeneration ) - let initialReferences = await initialReferences + let initialReferences = await initialReferencesTask var trackedChanges = await initialTrackedChanges // HEAD and index updates are separate filesystem operations. Reconcile // the reference signature after the index scan for every backend; the diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift index d13ea1765d63..feed79bc23b2 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift @@ -3,7 +3,7 @@ import Foundation extension GitMetadataService { /// Keeps a conservative root watcher when an index format cannot be parsed. - private nonisolated func applyingForcedWorkTreeRoots( + fileprivate nonisolated func applyingForcedWorkTreeRoots( _ descriptor: GitWorkspaceMetadataWatchDescriptor, repositories: Set ) -> GitWorkspaceMetadataWatchDescriptor { From 530e276d1bb4bc33d516b6a931e2dcc5b069a94a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:27:08 -0700 Subject: [PATCH 08/15] fix(build): expose git watch fallback helper --- .../CmuxGit/Parsing/GitMetadataService+WatchFallback.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift index feed79bc23b2..f10dbfe32abd 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchFallback.swift @@ -3,7 +3,7 @@ import Foundation extension GitMetadataService { /// Keeps a conservative root watcher when an index format cannot be parsed. - fileprivate nonisolated func applyingForcedWorkTreeRoots( + nonisolated func applyingForcedWorkTreeRoots( _ descriptor: GitWorkspaceMetadataWatchDescriptor, repositories: Set ) -> GitWorkspaceMetadataWatchDescriptor { From 324b58e8d32a9b90adf105b753a956d1f36185e2 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:34:30 -0700 Subject: [PATCH 09/15] fix(build): use explicit discovery assignment --- .../CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift index 06c569d010a8..b6382a8c99d1 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/PullRequestProbeService.swift @@ -105,14 +105,15 @@ public struct PullRequestProbeService: Sendable { let checkedOutBranch: GitCheckedOutBranch let remoteReadFailed: Bool if let directory = seed.directory { - let discovery = if let cached = discoveryByDirectory[directory] { - cached + let discovery: GitRepositoryDiscoverySnapshot + if let cached = discoveryByDirectory[directory] { + discovery = cached } else { let resolved = await gitMetadata.repositoryDiscoverySnapshot( forDirectory: directory ) discoveryByDirectory[directory] = resolved - resolved + discovery = resolved } repoSlugs = discovery.repositorySlugs checkedOutBranch = discovery.checkedOutBranch From aa70ea848d022fcdb8c2e92fb3bccedc5ef1a11c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:38:23 -0700 Subject: [PATCH 10/15] fix(build): define git reference snapshot initializer --- .../CmuxGit/Refs/GitReferenceSnapshot.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift index 883b1c5c66e7..21446a82b295 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift @@ -21,5 +21,19 @@ nonisolated struct GitReferenceSnapshot: Equatable, Sendable { let storageWatchPaths: [String] = [] /// Whether this snapshot used storage-independent Git plumbing. - let usesGitPlumbing: Bool = false + let usesGitPlumbing: Bool + + init( + checkedOutBranch: GitCheckedOutBranch, + headSignature: String?, + currentCommit: String?, + storageWatchPaths: [String] = [], + usesGitPlumbing: Bool = false + ) { + self.checkedOutBranch = checkedOutBranch + self.headSignature = headSignature + self.currentCommit = currentCommit + self.storageWatchPaths = storageWatchPaths + self.usesGitPlumbing = usesGitPlumbing + } } From 8f331d63e1f6e43b531bfcb7381022503ca0b746 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:41:36 -0700 Subject: [PATCH 11/15] fix(build): avoid statement-bearing git watch if expression --- .../Parsing/GitMetadataService+WatchPaths.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift index 7c9622fee8eb..013023a1ccda 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift @@ -70,17 +70,20 @@ extension GitMetadataService { $0.entryCount > safetyConfiguration.trackedEventPathCount || $0.fileByteCount > Int64(safetyConfiguration.directIndexByteCount) } ?? false - let indexSnapshot: GitIndexSnapshot? = if header != nil, !exceedsTrackedPathBudget { + let indexSnapshot: GitIndexSnapshot? + if header != nil, !exceedsTrackedPathBudget { let parser = GitIndexSnapshotParser() if let cached = indexSnapshotsByRepository?[repository.workTreeRoot], let data = indexReadResult.data, parser.signature(data: data) == cached.signature { - cached + indexSnapshot = cached } else { - indexReadResult.data.flatMap { parser.parse(data: $0, deadline: deadline) } + indexSnapshot = indexReadResult.data.flatMap { + parser.parse(data: $0, deadline: deadline) + } } } else { - nil + indexSnapshot = nil } let acceptsAllWorkTreeEvents = exceedsTrackedPathBudget let includesWorkTreeRoot = acceptsAllWorkTreeEvents From d80ac799088bd6eda0811c2742bb407937b3d7c0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:42:21 -0700 Subject: [PATCH 12/15] fix(build): return git reference snapshots from closure --- .../CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift index a0efe123b9fd..f2494f3c00f9 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitMetadataService+ReferenceSnapshot.swift @@ -122,12 +122,12 @@ extension GitMetadataService { ) } if revalidateFileBackedHead { - referenceReader.headSnapshot( + return referenceReader.headSnapshot( repository: repository, deadline: deadline ) } else { - referenceReader.snapshot( + return referenceReader.snapshot( repository: repository, deadline: deadline, includeStorageWatchPaths: includeStorageWatchPaths From b9d5d15a02c12d79ef7272e8413ed9aed3db8ec6 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sat, 29 Aug 2026 18:48:36 -0700 Subject: [PATCH 13/15] fix(build): align git metadata with Swift 6.2 --- .../Parsing/GitConfigTraversalBudget.swift | 18 ++++++- .../GitMetadataService+ConfigWatchPaths.swift | 53 ++++++++++++------- ...MetadataService+RepositoryResolution.swift | 2 +- .../GitMetadataService+WatchPaths.swift | 3 +- .../CmuxGit/Refs/GitReferenceSnapshot.swift | 2 +- .../SystemGitReferenceReader+Storage.swift | 8 +-- .../Refs/SystemGitReferenceReader.swift | 6 +-- 7 files changed, 62 insertions(+), 30 deletions(-) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift index f54273a233a7..40ab343fb10a 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitConfigTraversalBudget.swift @@ -11,7 +11,23 @@ nonisolated struct GitConfigTraversalBudget: Sendable { var didExhaustBudget = false let reader: GitConfigFileReader let maximumFileByteCount: Int - let deadline: DispatchTime? = nil + let deadline: DispatchTime? + + init( + remainingPathCount: Int, + remainingFileCount: Int, + remainingByteCount: Int, + reader: GitConfigFileReader, + maximumFileByteCount: Int, + deadline: DispatchTime? = nil + ) { + self.remainingPathCount = remainingPathCount + self.remainingFileCount = remainingFileCount + self.remainingByteCount = remainingByteCount + self.reader = reader + self.maximumFileByteCount = maximumFileByteCount + self.deadline = deadline + } var isExpired: Bool { if WorkspaceChangesCancellationSignal.isCurrentCancelled { diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift index 4b23662e7206..2e5b67cf479b 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift @@ -129,7 +129,7 @@ extension GitMetadataService { return (pathsByRepository, watchOnlyPathsByRepository, metadataSentinelsByRepository, indexSnapshotsByRepository, forceWorkTreeRoots, visitedRoots, remainingRepositoryCount) } - let indexPath = joinedPath(root: repository.gitDirectory, relativePath: "index") + let indexPath = Self.joinedPath(root: repository.gitDirectory, relativePath: "index") let indexResult = await watchIndexSnapshot( indexPath: indexPath, deadline: deadline, @@ -180,7 +180,7 @@ extension GitMetadataService { forceWorkTreeRoots.insert(repository.workTreeRoot) break } - let gitlinkPath = joinedPath( + let gitlinkPath = Self.joinedPath( root: repository.workTreeRoot, relativePath: entry.path ) @@ -322,13 +322,19 @@ extension GitMetadataService { ) let cancellationSignal = WorkspaceChangesCancellationSignal(deadline: deadline) return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - Self.blockingStatusQueue.async { + await withCheckedContinuation { + (continuation: CheckedContinuation) in + Self.blockingStatusQueue.async( + group: nil, + qos: .unspecified, + flags: [], + execute: { let result = cancellationSignal.withCurrentBinding { traversal.watchPathResult() } continuation.resume(returning: result) - } + } + ) } } onCancel: { cancellationSignal.cancel() @@ -344,10 +350,20 @@ extension GitMetadataService { ) async -> (header: GitIndexHeaderSummary?, snapshot: GitIndexSnapshot?) { let cancellationSignal = WorkspaceChangesCancellationSignal(deadline: deadline) return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - Self.blockingStatusQueue.async { + await withCheckedContinuation { + (continuation: CheckedContinuation<(header: GitIndexHeaderSummary?, snapshot: GitIndexSnapshot?), Never>) in + Self.blockingStatusQueue.async( + group: nil, + qos: .unspecified, + flags: [], + execute: { let result = cancellationSignal.withCurrentBinding { - guard deadline > DispatchTime.now() else { return (nil, nil) } + guard deadline > DispatchTime.now() else { + return ( + header: Optional.none, + snapshot: Optional.none + ) + } let readResult = GitIndexDataReader().read( at: URL(fileURLWithPath: indexPath), maximumByteCount: maximumFileByteCount, @@ -355,7 +371,7 @@ extension GitMetadataService { ) let parser = GitIndexSnapshotParser() let header = readResult.header - let snapshot = readResult.data.flatMap { data in + let snapshot: GitIndexSnapshot? = readResult.data.flatMap { data in guard let header, header.entryCount <= maximumEntryCount else { return nil @@ -365,7 +381,8 @@ extension GitMetadataService { return (header, snapshot) } continuation.resume(returning: result) - } + } + ) } } onCancel: { cancellationSignal.cancel() @@ -432,8 +449,8 @@ extension GitMetadataService { let descriptor = cancellationSignal.withCurrentBinding { Self.workspaceGitMetadataWatchDescriptor( for: directory, - resolvedRepository: repository, safetyConfiguration: safetyConfiguration, + resolvedRepository: repository, configPathsByRepository: watchInputs.configPathsByRepository, watchOnlyPathsByRepository: watchInputs.watchOnlyPathsByRepository, metadataSentinelPathsByRepository: watchInputs.metadataSentinelPathsByRepository, @@ -454,13 +471,13 @@ extension GitMetadataService { deadline: DispatchTime ) -> [String] { [ - joinedPath(root: repository.gitDirectory, relativePath: "HEAD"), - joinedPath(root: repository.gitDirectory, relativePath: "index"), - joinedPath(root: repository.gitDirectory, relativePath: "refs"), - joinedPath(root: repository.gitDirectory, relativePath: "reftable"), - joinedPath(root: repository.commonDirectory, relativePath: "refs"), - joinedPath(root: repository.commonDirectory, relativePath: "packed-refs"), - joinedPath(root: repository.commonDirectory, relativePath: "reftable"), + Self.joinedPath(root: repository.gitDirectory, relativePath: "HEAD"), + Self.joinedPath(root: repository.gitDirectory, relativePath: "index"), + Self.joinedPath(root: repository.gitDirectory, relativePath: "refs"), + Self.joinedPath(root: repository.gitDirectory, relativePath: "reftable"), + Self.joinedPath(root: repository.commonDirectory, relativePath: "refs"), + Self.joinedPath(root: repository.commonDirectory, relativePath: "packed-refs"), + Self.joinedPath(root: repository.commonDirectory, relativePath: "reftable"), ] + GitWorktreeConfigEnablementReader() .rootConfigURLs(repository: repository, deadline: deadline) .map(\.path) diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+RepositoryResolution.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+RepositoryResolution.swift index 3aff34de9a23..836ee8f0588e 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+RepositoryResolution.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+RepositoryResolution.swift @@ -75,7 +75,7 @@ extension GitMetadataService { return await withTaskCancellationHandler { await withCheckedContinuation { continuation in Self.blockingStatusQueue.async { - let repository = cancellationSignal.withCurrentBinding { + let repository: ResolvedGitRepository? = cancellationSignal.withCurrentBinding { guard deadline > DispatchTime.now() else { return nil } return Self.resolveGitRepository(containing: directory, deadline: deadline) } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift index 013023a1ccda..cca71aa48f76 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift @@ -46,7 +46,6 @@ extension GitMetadataService { (watchOnlyPathsByRepository?.values.flatMap { $0 } ?? []) + metadataSentinelParentPaths ) - let watchOnlyPathSet = Set(watchOnlyPaths) let gitMetadataPaths = gitRepositoryMetadataWatchPaths( repository: repository, configPathsByRepository: configPathsByRepository @@ -170,7 +169,7 @@ extension GitMetadataService { } else { gitConfigURLs(repository: repository).map(\.path) } - [ + return [ joinedPath(root: repository.gitDirectory, relativePath: "HEAD"), joinedPath(root: repository.gitDirectory, relativePath: "index"), joinedPath(root: repository.gitDirectory, relativePath: "refs"), diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift index 21446a82b295..5469fd6fcb98 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceSnapshot.swift @@ -18,7 +18,7 @@ nonisolated struct GitReferenceSnapshot: Equatable, Sendable { } /// Additional bounded storage paths Git reports for watcher invalidation. - let storageWatchPaths: [String] = [] + let storageWatchPaths: [String] /// Whether this snapshot used storage-independent Git plumbing. let usesGitPlumbing: Bool diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift index 65bd939feb46..72dfc58185fb 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift @@ -191,7 +191,7 @@ extension SystemGitReferenceReader { maximumByteCount: Self.maximumDirectObjectIDByteCount, deadline: deadline ) { - case .contents(let contents, consumedByteCount: byteCount): + case let .contents(contents, consumedByteCount: byteCount): return .contents(contents, consumedByteCount: byteCount) case .missing: continue @@ -213,9 +213,9 @@ extension SystemGitReferenceReader { return .missing case .oversized, .unavailable: return .unavailable(consumedByteCount: 0) - case .contents(let contents, consumedByteCount: byteCount): - for rawLine in contents.split(whereSeparator: \.isNewline) { - let line = rawLine.trimmingCharacters(in: .whitespaces) + case let .contents(contents, consumedByteCount: byteCount): + for rawLine in contents.split(whereSeparator: { $0.isNewline }) { + let line = rawLine.trimmingCharacters(in: CharacterSet.whitespaces) guard !line.isEmpty, !line.hasPrefix("#"), !line.hasPrefix("^") else { continue } let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) guard parts.count == 2, String(parts[1]) == refName else { continue } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift index 1c3c245f80e7..49890cf6e947 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift @@ -149,13 +149,13 @@ nonisolated struct SystemGitReferenceReader: GitReferenceReading { guard directSnapshot.currentCommit == nil || directSnapshot.branchName == ".invalid" else { return directSnapshot } - let configuredStorage = configuredStorage + let resolvedStorage = configuredStorage ?? referenceStorageName( repository: repository, branchContext: .resolved(directSnapshot.branchName), deadline: deadline ) - if let configuredStorage, configuredStorage != "files" { + if let resolvedStorage, resolvedStorage != "files" { return plumbingSnapshot( repository: repository, deadline: deadline, @@ -226,7 +226,7 @@ nonisolated struct SystemGitReferenceReader: GitReferenceReading { ) -> Bool { let effectiveDeadline = deadline ?? (DispatchTime.now() + boundedCommandWallTimeLimit) - [repository.gitDirectory, repository.commonDirectory].contains { directory in + return [repository.gitDirectory, repository.commonDirectory].contains { directory in guard effectiveDeadline > DispatchTime.now() else { return false } let reftableDirectory = URL(fileURLWithPath: directory) .appendingPathComponent("reftable", isDirectory: true) From 380d2c9defb8d6b403d486d5cb4644cead9b8ea0 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 30 Aug 2026 17:52:19 -0700 Subject: [PATCH 14/15] fix(mac): show only Tailscale pairing QR --- .../Mobile/Pairing/MobilePairingView.swift | 100 ++++-------------- 1 file changed, 18 insertions(+), 82 deletions(-) diff --git a/Sources/Mobile/Pairing/MobilePairingView.swift b/Sources/Mobile/Pairing/MobilePairingView.swift index b70ac97b1571..8f7b50b1658a 100644 --- a/Sources/Mobile/Pairing/MobilePairingView.swift +++ b/Sources/Mobile/Pairing/MobilePairingView.swift @@ -6,26 +6,14 @@ import SwiftUI /// The macOS window for pairing an iPhone with this Mac. /// -/// A transport chooser leads the page: Iroh pairing is automatic for -/// signed-in iPhones and needs no QR, while the Tailscale tab shows the QR -/// used when the iPhone's connection method is explicitly set to Tailscale. -/// Each tab ends in a status row that doubles as the debugging surface -/// (live transport state, manual-entry routes, signed-in account). +/// The page presents one pairing artifact: a Tailscale QR for signed-in +/// iPhones that use the explicit Tailscale connection method. Iroh remains an +/// automatic, no-QR discovery path and is shown only as status information. struct MobilePairingView: View { - /// The transport whose pairing flow the window presents. - enum TransportChoice: Hashable { - case iroh - case tailscale - } - @State private var model = MobilePairingModel() @State private var signInModel = AccountSignInModel( flow: AppDelegate.shared?.auth?.accountFlow ) - /// The user's explicit tab pick. `nil` until they touch the chooser; the - /// effective tab then follows Iroh readiness (Iroh when ready, else - /// Tailscale) so the page opens on the transport that will work. - @State private var chosenTransport: TransportChoice? /// The manual-entry value that was just copied (the host or the port /// string), so only the matching button shows the brief "Copied" flash. /// The two values can never collide: one is a host, the other a port. @@ -98,29 +86,6 @@ struct MobilePairingView: View { } } - // MARK: Transport chooser - - private func effectiveTransport(reachableViaIroh: Bool) -> TransportChoice { - chosenTransport ?? (reachableViaIroh ? .iroh : .tailscale) - } - - private func transportPicker(reachableViaIroh: Bool) -> some View { - Picker( - String(localized: "mobile.pairing.transportPicker", defaultValue: "Connection"), - selection: Binding( - get: { effectiveTransport(reachableViaIroh: reachableViaIroh) }, - set: { chosenTransport = $0 } - ) - ) { - // Transport product names are literal tokens, not translatable copy. - Text(verbatim: "Iroh").tag(TransportChoice.iroh) - Text(verbatim: "Tailscale").tag(TransportChoice.tailscale) - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 280) - } - /// The App Store-badge-styled button for getting cmux on the iPhone. private var getIPhoneAppBadge: some View { Link(destination: Self.iphoneAppURL) { @@ -215,27 +180,19 @@ struct MobilePairingView: View { @ViewBuilder private func readyContent(_ ready: MobilePairingModel.Ready) -> some View { - let transport = effectiveTransport(reachableViaIroh: ready.reachableViaIroh) - VStack(alignment: .center, spacing: 14) { - transportPicker(reachableViaIroh: ready.reachableViaIroh) getIPhoneAppBadge - if transport == .tailscale { - tailscaleReadyBody(ready) - } else { - irohBody(waiting: ready.reachableViaIroh) - } + tailscaleReadyBody(ready) } .frame(maxWidth: .infinity) Divider() - if transport == .tailscale { - tailscaleRow(ready) - manualEntry(ready) - } else { + tailscaleRow(ready) + if ready.reachableViaIroh { irohRow(reachableViaIroh: ready.reachableViaIroh) } + manualEntry(ready) footer } @@ -274,30 +231,6 @@ struct MobilePairingView: View { } } - @ViewBuilder - private func irohBody(waiting: Bool) -> some View { - Text(String( - localized: "mobile.pairing.irohInstruction", - defaultValue: "Install cmux on your iPhone and sign in with the same account. It connects automatically — no code needed." - )) - .cmuxFont(.callout) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: 420) - - if waiting { - waitingIndicator - } - - // The selected iOS app matters beyond the QR: it addresses the - // paired-Mac records Iroh discovery hands to that exact app, so the - // picker stays available on the automatic path too. - if model.availableIOSAppTargets.count > 1 { - pairingTargetPicker - } - } - private var waitingIndicator: some View { HStack(spacing: 6) { ProgressView().controlSize(.small) @@ -350,22 +283,25 @@ struct MobilePairingView: View { @ViewBuilder private func needsReachableTransportContent(reachableViaIroh: Bool) -> some View { - let transport = effectiveTransport(reachableViaIroh: reachableViaIroh) - VStack(alignment: .center, spacing: 14) { - transportPicker(reachableViaIroh: reachableViaIroh) getIPhoneAppBadge - if transport == .iroh { - irohBody(waiting: reachableViaIroh) - } else { - tailscaleMissingBody + tailscaleMissingBody + if reachableViaIroh { + Text(String( + localized: "mobile.pairing.irohInstruction", + defaultValue: "Install cmux on your iPhone and sign in with the same account. It connects automatically — no code needed." + )) + .cmuxFont(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) } } .frame(maxWidth: .infinity) Divider() - if transport == .iroh { + if reachableViaIroh { irohRow(reachableViaIroh: reachableViaIroh) } From 8416b5daaee7660de6e328de3ada0af0bf2443fc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Sun, 30 Aug 2026 17:58:22 -0700 Subject: [PATCH 15/15] fix(ios): complete pairing review follow-ups --- .../MobileShellComposite.swift | 12 ++ .../Changes/GitExecutableFileProbing.swift | 2 +- .../GitMetadataService+ConfigWatchPaths.swift | 10 +- .../GitMetadataService+WatchPaths.swift | 4 +- .../Refs/GitReferenceStorageProbing.swift | 2 +- .../SystemGitReferenceReader+Storage.swift | 8 +- .../Refs/SystemGitReferenceReader.swift | 4 +- Resources/Localizable.xcstrings | 108 ++++++++++++++++++ 8 files changed, 133 insertions(+), 17 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 0b3f7d3e0738..c586354fed51 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2577,6 +2577,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() + analytics.capture("ios_pairing_failed", [ + "method": .string("manual"), + "reason": .string("invalid_host"), + "failure_phase": .string("validation"), + "is_first_pair": .bool(!hasKnownPairedMac), + ]) return } @@ -2597,6 +2603,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { connectionState = .disconnected macConnectionStatus = .unavailable clearRemoteConnectionContext() + analytics.capture("ios_pairing_failed", [ + "method": .string("manual"), + "reason": .string("loopback_rejected"), + "failure_phase": .string("validation"), + "is_first_pair": .bool(!hasKnownPairedMac), + ]) return } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift index 57d341e04e01..1b6c52a14a3a 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Changes/GitExecutableFileProbing.swift @@ -10,7 +10,7 @@ nonisolated protocol GitExecutableFileProbing: Sendable { nonisolated struct SystemGitExecutableFileProbe: GitExecutableFileProbing { func isExecutableFile(atPath path: String) -> Bool { var metadata = stat() - return path.withCString { stat($0, &metadata) == 0 } + return stat(path, &metadata) == 0 && metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) && Darwin.access(path, X_OK) == 0 } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift index 352edc8913a7..9d294ba1bc3c 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+ConfigWatchPaths.swift @@ -322,8 +322,7 @@ extension GitMetadataService { ) let cancellationSignal = WorkspaceChangesCancellationSignal(deadline: deadline) return await withTaskCancellationHandler { - await withCheckedContinuation { - (continuation: CheckedContinuation) in + await withCheckedContinuation { continuation in Self.blockingStatusQueue.async(execute: DispatchWorkItem(block: { let result = cancellationSignal.withCurrentBinding { traversal.watchPathResult() @@ -345,14 +344,13 @@ extension GitMetadataService { ) async -> (header: GitIndexHeaderSummary?, snapshot: GitIndexSnapshot?) { let cancellationSignal = WorkspaceChangesCancellationSignal(deadline: deadline) return await withTaskCancellationHandler { - await withCheckedContinuation { - (continuation: CheckedContinuation<(header: GitIndexHeaderSummary?, snapshot: GitIndexSnapshot?), Never>) in + await withCheckedContinuation { continuation in Self.blockingStatusQueue.async(execute: DispatchWorkItem(block: { let result = cancellationSignal.withCurrentBinding { guard deadline > DispatchTime.now() else { return ( - header: Optional.none, - snapshot: Optional.none + nil as GitIndexHeaderSummary?, + nil as GitIndexSnapshot? ) } let readResult = GitIndexDataReader().read( diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift index 22626d9f84ea..2c119d05743f 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+WatchPaths.swift @@ -77,9 +77,7 @@ extension GitMetadataService { parser.signature(data: data) == cached.signature { indexSnapshot = cached } else { - indexSnapshot = indexReadResult.data.flatMap { - parser.parse(data: $0, deadline: deadline) - } + indexSnapshot = indexReadResult.data.flatMap { parser.parse(data: $0, deadline: deadline) } } } else { indexSnapshot = nil diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift index 4bca68356753..b73c2666785d 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/GitReferenceStorageProbing.swift @@ -10,7 +10,7 @@ nonisolated protocol GitReferenceStorageProbing: Sendable { nonisolated struct SystemGitReferenceStorageProbe: GitReferenceStorageProbing { func isDirectory(atPath path: String) -> Bool { var metadata = stat() - return path.withCString { stat($0, &metadata) == 0 } + return stat(path, &metadata) == 0 && metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR) } } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift index 72dfc58185fb..ab85c118db55 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader+Storage.swift @@ -191,7 +191,7 @@ extension SystemGitReferenceReader { maximumByteCount: Self.maximumDirectObjectIDByteCount, deadline: deadline ) { - case let .contents(contents, consumedByteCount: byteCount): + case .contents(let contents, consumedByteCount: let byteCount): return .contents(contents, consumedByteCount: byteCount) case .missing: continue @@ -213,9 +213,9 @@ extension SystemGitReferenceReader { return .missing case .oversized, .unavailable: return .unavailable(consumedByteCount: 0) - case let .contents(contents, consumedByteCount: byteCount): - for rawLine in contents.split(whereSeparator: { $0.isNewline }) { - let line = rawLine.trimmingCharacters(in: CharacterSet.whitespaces) + case .contents(let contents, consumedByteCount: let byteCount): + for rawLine in contents.split(whereSeparator: \.isNewline) { + let line = rawLine.trimmingCharacters(in: .whitespaces) guard !line.isEmpty, !line.hasPrefix("#"), !line.hasPrefix("^") else { continue } let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) guard parts.count == 2, String(parts[1]) == refName else { continue } diff --git a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift index 49890cf6e947..8bc95df13305 100644 --- a/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift +++ b/Packages/macOS/CmuxGit/Sources/CmuxGit/Refs/SystemGitReferenceReader.swift @@ -149,13 +149,13 @@ nonisolated struct SystemGitReferenceReader: GitReferenceReading { guard directSnapshot.currentCommit == nil || directSnapshot.branchName == ".invalid" else { return directSnapshot } - let resolvedStorage = configuredStorage + let effectiveConfiguredStorage = configuredStorage ?? referenceStorageName( repository: repository, branchContext: .resolved(directSnapshot.branchName), deadline: deadline ) - if let resolvedStorage, resolvedStorage != "files" { + if let effectiveConfiguredStorage, effectiveConfiguredStorage != "files" { return plumbingSnapshot( repository: repository, deadline: deadline, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 99807ae39d1b..223a10524081 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -145446,17 +145446,125 @@ "mobile.pairing.manual.title": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يمكنك المسح؟ أدخل عنوان IP الرقمي لـ Tailscale لهذا الـ Mac والمنفذ:" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ne možete skenirati? Unesite numeričku Tailscale IP adresu i port ovog Maca:" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kan du ikke scanne? Indtast denne Macs numeriske Tailscale-IP-adresse og port:" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Du kannst nicht scannen? Gib die numerische Tailscale-IP-Adresse und den Port dieses Mac ein:" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Can't scan? Enter this Mac's numeric Tailscale IP and port:" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "¿No puedes escanear? Introduce la dirección IP numérica de Tailscale y el puerto de este Mac:" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de scanner ? Saisissez l’adresse IP Tailscale numérique et le port de ce Mac :" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non riesci a scansionare? Inserisci l’indirizzo IP numerico Tailscale e la porta di questo Mac:" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "スキャンできない場合は、この Mac の数値 Tailscale IP アドレスとポートを入力してください:" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ស្កេនមិនបានមែនទេ? បញ្ចូល IP Tailscale ជាលេខ និងច្រករបស់ Mac នេះ៖" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "스캔할 수 없나요? 이 Mac의 숫자 Tailscale IP와 포트를 입력하세요:" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kan du ikke skanne? Skriv inn det numeriske Tailscale-IP-et og porten til denne Mac-en:" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie możesz zeskanować? Wpisz numeryczny adres IP Tailscale i port tego Maca:" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não consegue escanear? Digite o IP numérico do Tailscale e a porta deste Mac:" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удаётся отсканировать? Введите числовой IP-адрес Tailscale и порт этого Mac:" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สแกนไม่ได้ใช่ไหม ป้อน IP Tailscale แบบตัวเลขและพอร์ตของ Mac เครื่องนี้:" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayamıyor musunuz? Bu Mac'in sayısal Tailscale IP adresini ve bağlantı noktasını girin:" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдається відсканувати? Введіть числову IP-адресу Tailscale і порт цього Mac:" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法扫描?请输入这台 Mac 的数字 Tailscale IP 和端口:" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法掃描?請輸入這部 Mac 的數字 Tailscale IP 與連接埠:" + } } } },