Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
117 commits
Select commit Hold shift + click to select a range
ce6b507
fix cloud terminal observability and first replay redraw
lawrencecchen Sep 13, 2026
e902381
merge cloud terminal attachment recovery
lawrencecchen Sep 13, 2026
de24b02
merge latest main into Cloud terminal recovery
lawrencecchen Sep 13, 2026
c21d596
merge Cloud terminal creation recovery
lawrencecchen Sep 13, 2026
81721c4
fix cloud terminal split placeholders and replay rendering
lawrencecchen Sep 13, 2026
9a40ccc
split cloud mirror protocol handling from lifecycle
lawrencecchen Sep 13, 2026
51783c6
merge latest main for Cloud terminal fix
lawrencecchen Sep 13, 2026
3ccd130
fix pending Cloud pane cancellation fence
lawrencecchen Sep 13, 2026
f2f0712
fix use pinned Ghostty OSC8 action enum
lawrencecchen Sep 13, 2026
fb7529a
trim Ghostty compatibility change
lawrencecchen Sep 13, 2026
5ad13bc
fix cloud mirror protocol access across files
lawrencecchen Sep 13, 2026
43cf284
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 13, 2026
e871426
fix quote protocol source path in project
lawrencecchen Sep 13, 2026
7c81622
remove duplicate cloud pane routing extension
lawrencecchen Sep 13, 2026
fa65821
fix replay redraw log interpolation
lawrencecchen Sep 13, 2026
fad66ba
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 13, 2026
389d16d
fix escaping cloud tree operation runner
lawrencecchen Sep 13, 2026
daa94ed
fix cross-file protocol state and test polling
lawrencecchen Sep 13, 2026
ac1a4b4
test: catch renderer claiming presentation before a frame
austinywang Sep 11, 2026
09022a9
fix: recover and diagnose blank terminal surfaces
austinywang Sep 12, 2026
c96135b
feat: show terminal render health in tree output
austinywang Sep 12, 2026
6cdb9bc
fix: gate renderer probe draw-end recovery
austinywang Sep 12, 2026
98d10ad
fix: log terminal render health transitions
austinywang Sep 12, 2026
0a1c296
refactor: own terminal health overlay separately
austinywang Sep 12, 2026
9645f6e
fix: keep render health overlay on main actor
austinywang Sep 12, 2026
f4e5d68
fix: expose render health within terminal module
austinywang Sep 12, 2026
54b4921
test: acknowledge deferred first presentation
austinywang Sep 12, 2026
322f128
fix: harden renderer health lifecycle and callback tests
austinywang Sep 12, 2026
d7c2e02
fix: keep shell exit health through failed probes
austinywang Sep 12, 2026
7280256
fix: keep health diagnostics inside terminal content
austinywang Sep 12, 2026
ab805bd
test: remove app-target overlay test from package
austinywang Sep 12, 2026
f1f7136
fix: reset renderer recovery on window visibility
austinywang Sep 12, 2026
9ee3299
fix: preserve rendered health across window occlusion
austinywang Sep 12, 2026
d315f22
test: cover portal recovery episodes
austinywang Sep 12, 2026
34e8f02
test: match tokened probe admission semantics
austinywang Sep 12, 2026
ff4b361
remove obsolete redraw and trim changed files
lawrencecchen Sep 13, 2026
fd7afc0
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 13, 2026
751dd07
Restore headless Cloud terminal provider methods
lawrencecchen Sep 13, 2026
ac10a21
fix use public Bonsplit tab UUID in cloud layout
lawrencecchen Sep 13, 2026
be8b61d
Fix Cloud environment cleanup call
lawrencecchen Sep 13, 2026
98a8b78
fix Cloud file cleanup call
lawrencecchen Sep 13, 2026
6bbf9b7
fix Cloud workspace target helper call
lawrencecchen Sep 13, 2026
78d8c02
remove unneeded Cloud protocol extraction
lawrencecchen Sep 13, 2026
c3c46a5
fix renderer callback test fixtures
lawrencecchen Sep 13, 2026
7d5b649
fix renderer health test callback lifecycle
lawrencecchen Sep 13, 2026
3df1712
test: reproduce stale Cloud presentation acknowledgements
lawrencecchen Sep 13, 2026
ea35be2
fix: bind Cloud frame proof to pane and replay ownership
lawrencecchen Sep 13, 2026
ce3b49e
merge main and surface typed Cloud failures
lawrencecchen Sep 13, 2026
c9ec5d8
style: keep merged source within file budgets
lawrencecchen Sep 13, 2026
0c69817
fix: remove duplicate Cloud terminal IO methods
lawrencecchen Sep 13, 2026
1a5d96f
test: cover repeated Cloud terminal projection opens
lawrencecchen Sep 13, 2026
89e7ca1
fix: wire render health overlay into app target
lawrencecchen Sep 13, 2026
76deabe
fix: keep Cloud error guidance consistent with redaction
lawrencecchen Sep 13, 2026
4197434
test: avoid nested Swift Testing require
lawrencecchen Sep 13, 2026
71a747f
style: keep provider test within file budget
lawrencecchen Sep 13, 2026
ed6bd48
test: split throwing Cloud fixture assertions
lawrencecchen Sep 13, 2026
ca3bc3a
test: evaluate mutable readiness gates before assertions
lawrencecchen Sep 13, 2026
d25dcce
test: exercise Cloud socket errors and correct hidden-pane assertions
lawrencecchen Sep 13, 2026
3b946d8
fix: reject stale explicit Cloud destinations
lawrencecchen Sep 13, 2026
b012aea
fix: preserve Cloud catalog error details
lawrencecchen Sep 13, 2026
f48185b
test: anchor Cloud projections to fixture workspace
lawrencecchen Sep 13, 2026
4dd5e23
fix: validate explicit Cloud workspace ownership
lawrencecchen Sep 13, 2026
0598b55
fix: make Cloud readiness layer independent
lawrencecchen Sep 13, 2026
d2e9314
fix: allow presentation callback sequence updates
lawrencecchen Sep 13, 2026
ad50fc6
fix: keep Cloud mirror focus on its own panel
lawrencecchen Sep 13, 2026
a6e24e1
fix: return Cloud focus target decision
lawrencecchen Sep 13, 2026
8808f33
fix: hand explicit Cloud opens keyboard focus
lawrencecchen Sep 13, 2026
7b98bcc
fix: validate Cloud panes within explicit workspace
lawrencecchen Sep 13, 2026
65d1693
Merge origin/main into feat-cloud-terminal-blank-rootcause
lawrencecchen Sep 13, 2026
2c920e8
fix: remove duplicate provider declarations after main merge
lawrencecchen Sep 14, 2026
7e12aef
fix: align cloud row rendering with main
lawrencecchen Sep 14, 2026
21a16cc
fix: restore cloud workspace rename helper
lawrencecchen Sep 14, 2026
29af1a4
Merge origin/main Cloud lifecycle updates
lawrencecchen Sep 14, 2026
ac2ac6f
fix: recover cloud placement for terminal splits
lawrencecchen Sep 14, 2026
8bd574c
fix: restore cloud close terminal source after main merge
lawrencecchen Sep 14, 2026
1495ec1
fix: reconcile latest main build sources
lawrencecchen Sep 14, 2026
7c6ce1a
fix: link render health overlay with app target
lawrencecchen Sep 14, 2026
bea187d
fix: preserve provider helpers and test wiring after merge
lawrencecchen Sep 14, 2026
dcfad5c
test: reject false Cloud snapshot conflicts after tab close
lawrencecchen Sep 14, 2026
125e6e2
fix: normalize omitted Cloud lifecycle fields
lawrencecchen Sep 14, 2026
635b739
fix: localize Cloud placement failure
lawrencecchen Sep 14, 2026
f29eb60
fix: place Cloud creation errors above portal terminals
lawrencecchen Sep 14, 2026
c12641c
fix: host Cloud failure card above portal layer
lawrencecchen Sep 14, 2026
c16eacb
Merge origin/main renderer refactor
lawrencecchen Sep 14, 2026
2ded295
fix: drop superseded renderer proof source
lawrencecchen Sep 14, 2026
55ab5da
fix: remove duplicate pending creation helpers after main merge
lawrencecchen Sep 14, 2026
cbefa0f
fix: use shared Cloud graph validation after main migration
lawrencecchen Sep 14, 2026
751674c
test: keep Cloud failure cards within their visible workspace
lawrencecchen Sep 14, 2026
6f3a154
fix: scope native Cloud errors to visible workspace geometry
lawrencecchen Sep 14, 2026
8b06d5a
chore: deduplicate renderer overlay group reference
lawrencecchen Sep 14, 2026
a8679ba
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 14, 2026
563c4ef
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 14, 2026
cd7ed26
chore: remove duplicate renderer overlay group entry
lawrencecchen Sep 14, 2026
4510550
test: cover Cloud surface targets in another window
lawrencecchen Sep 14, 2026
cae7680
fix: resolve Cloud surface targets through their live owner
lawrencecchen Sep 14, 2026
8b954ee
test: compare Cloud graph rows independently of wire order
lawrencecchen Sep 14, 2026
0a98eaa
fix: compare Cloud resource graphs by stable identity
lawrencecchen Sep 14, 2026
63cca5a
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 14, 2026
7461c98
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 14, 2026
1b79046
fix: ignore Cloud session envelope in revision comparison
lawrencecchen Sep 14, 2026
74295a9
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 15, 2026
9587ead
fix: fail closed when Cloud terminal routing is unavailable
lawrencecchen Sep 15, 2026
9e41945
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 15, 2026
872e077
fix cloud retry actions and preserve layout intent keys
lawrencecchen Sep 15, 2026
c1a1382
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 17, 2026
718b1ba
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 17, 2026
6caea0f
test: keep Cloud reconnect cards within narrow panes
lawrencecchen Sep 17, 2026
63c78c5
fix: fit Cloud reconnect cards to their pane width
lawrencecchen Sep 17, 2026
3d21059
Merge remote-tracking branch 'origin/main' into feat-cloud-terminal-b…
lawrencecchen Sep 17, 2026
03d86da
fix cloud action closure type
lawrencecchen Sep 17, 2026
8bc646b
fix cloud action task closure capture
lawrencecchen Sep 17, 2026
9aced12
fix: restore pairing deadline dropped by main merge
lawrencecchen Sep 17, 2026
181d8fa
test: locate Cloud card buttons by accessibility identity
lawrencecchen Sep 17, 2026
185d708
Fix Cloud test imports and nested Swift Testing macro
lawrencecchen Sep 17, 2026
d93557b
test: import the owning module for remote workspace configuration
lawrencecchen Sep 17, 2026
e63a642
test: cover retained Cloud ownership and current recovery behavior
lawrencecchen Sep 17, 2026
16f87d9
fix: retain Cloud routing while the provider graph is unavailable
lawrencecchen Sep 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -259926,6 +259926,65 @@
}
}
},
"mobile.pairing.error.preparationFailed": {
"extractionState": "manual",
"localizations": {
"ar": {
"stringUnit": {
"state": "translated",
"value": "تعذّر إكمال الاقتران. تحقق من الاتصال وحاول مرة أخرى."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Die Kopplung konnte nicht abgeschlossen werden. Überprüfe deine Verbindung und versuche es erneut."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Pairing could not finish. Check your connection and try again."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "No se ha podido completar el enlace. Comprueba tu conexión e inténtalo de nuevo."
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Le jumelage n’a pas pu être terminé. Vérifiez votre connexion et réessayez."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ペアリングを完了できませんでした。接続を確認して、もう一度お試しください。"
}
},
"ko": {
"stringUnit": {
"state": "translated",
"value": "페어링을 완료하지 못했습니다. 연결을 확인하고 다시 시도하세요."
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "无法完成配对。请检查连接后重试。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "無法完成配對。請檢查連線後再試一次。"
}
}
}
},
"mobile.pairing.error.noTicket": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -518601,6 +518660,20 @@
}
}
},
"cloud.operation.failure.placement": {
"extractionState": "manual",
"localizations": {
"en": {"stringUnit": {"state": "translated", "value": "The Cloud terminal placement is unavailable. Refresh the machine, then retry."}},
"de": {"stringUnit": {"state": "translated", "value": "Die Cloud-Terminalplatzierung ist nicht verfügbar. Aktualisieren Sie die Maschine und versuchen Sie es erneut."}},
"fr": {"stringUnit": {"state": "translated", "value": "L’emplacement du terminal Cloud n’est pas disponible. Actualisez la machine, puis réessayez."}},
"ar": {"stringUnit": {"state": "translated", "value": "موضع طرفية Cloud غير متاح. حدّث الجهاز ثم أعد المحاولة."}},
"es": {"stringUnit": {"state": "translated", "value": "La ubicación del terminal de Cloud no está disponible. Actualiza la máquina y vuelve a intentarlo."}},
"zh-Hant": {"stringUnit": {"state": "translated", "value": "Cloud 終端機位置無法使用。請重新整理機器後再試一次。"}},
"zh-Hans": {"stringUnit": {"state": "translated", "value": "Cloud 终端位置不可用。请刷新机器后重试。"}},
"ko": {"stringUnit": {"state": "translated", "value": "Cloud 터미널 배치를 사용할 수 없습니다. 머신을 새로 고친 후 다시 시도하세요."}},
"ja": {"stringUnit": {"state": "translated", "value": "Cloud ターミナルの配置を利用できません。マシンを更新して、もう一度お試しください。"}}
}
},
"cloud.operation.failure.unsupported": {
"extractionState": "manual",
"localizations": {
Expand Down
6 changes: 3 additions & 3 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2542,16 +2542,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
(settingsRuntime.hostActions as? HostSettingsActions)?.setRunComputerUseOnboardingAction { [weak self] startingPoint in
self?.computerUseUXCoordinator.presentOnboardingFromSettings(startingAt: startingPoint)
}
let cloudTunnel = makeCloudTunnelCoordinator()
cloudTunnelCoordinator = cloudTunnel
CmuxTuiSurfaceProviderRegistry.shared.portAccess.coordinator = cloudTunnel
let cloudUploader = CloudTelemetryUploader(
auth: auth.coordinator, baseURL: AuthEnvironment.vmAPIBaseURL, client: .current()
)
let cloudOperations = CloudOperationRecorder(uploader: cloudUploader, identity: { [weak coordinator = auth.coordinator] in
coordinator?.authenticatedSessionIdentity
})
self.cloudOperations = cloudOperations
let cloudTunnel = makeCloudTunnelCoordinator()
cloudTunnelCoordinator = cloudTunnel
CmuxTuiSurfaceProviderRegistry.shared.portAccess.coordinator = cloudTunnel
VMClient.bootstrap(auth: auth.coordinator, operations: cloudOperations)
TerminalController.shared.cloudTunnel = cloudTunnel
RemotesClient.bootstrap(auth: auth.coordinator)
Expand Down
13 changes: 8 additions & 5 deletions Sources/Cloud/CloudDiagnosticFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
enum CloudDiagnosticFailure: String, Codable, Sendable, Error {
case authentication, sessionRefresh = "session_refresh", permission, plan
case rateLimit = "rate_limit", conflict, network, timeout, server, response, unsupported
case process, `protocol`, notFound = "not_found", resourceLimit = "resource_limit"
case process, `protocol`, notFound = "not_found", placement, resourceLimit = "resource_limit"
case storage, cancelled, unknown

var label: String {
Expand All @@ -22,6 +22,8 @@ enum CloudDiagnosticFailure: String, Codable, Sendable, Error {
return String(localized: "cloud.operation.failure.conflict", defaultValue: "Another operation changed this machine. Refresh its state.")
case .notFound:
return String(localized: "cloud.operation.failure.notFound", defaultValue: "The Cloud resource is no longer available. Refresh the machine list.")
case .placement:
return String(localized: "cloud.operation.failure.placement", defaultValue: "The Cloud terminal placement is unavailable. Refresh the machine, then retry.")
case .unsupported:
return String(localized: "cloud.operation.failure.unsupported", defaultValue: "This Cloud operation is not supported. Check for an update.")
case .cancelled:
Expand Down Expand Up @@ -59,12 +61,13 @@ enum CloudDiagnosticFailure: String, Codable, Sendable, Error {
if let error = error as? CmuxTuiSurfaceProvider.ProviderError {
switch error {
case .notSignedIn: return .authentication
case .machineAsleep, .remoteWorkspaceNotFound, .remoteTabNotFound,
.noWorkspaceOnMachine, .remotePlacementUnavailable, .terminalExited: return .notFound
case .terminalNotCreated, .invalidSnapshot, .stateUnavailable,
.invalidPreviewURL, .localForwardURLUnavailable: return .response
case .machineAsleep, .remoteWorkspaceNotFound, .remoteTabNotFound: return .notFound
case .noWorkspaceOnMachine, .remotePlacementUnavailable: return .placement
case .terminalNotCreated, .terminalExited: return .process
case .terminalAttachTimedOut: return .timeout
case .invalidSnapshot, .stateUnavailable: return .response
case .snapshotOnly, .hubUnavailable: return .unsupported
case .invalidPreviewURL, .localForwardURLUnavailable: return .response
}
}
if error is CloudMachineLinkManager.ManagerError { return .connectFailure(error) }
Expand Down
10 changes: 5 additions & 5 deletions Sources/Cloud/CloudTreeNodeActions.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import AppKit
import Foundation

/// Closure bundle handed to Cloud outline rows for the nodes below a machine.
struct CloudTreeNodeActions {
/// Project a resource into the selected local workspace.
Expand Down Expand Up @@ -63,8 +62,11 @@ struct CloudTreeNodeActions {
refresh: @escaping @MainActor () -> Void,
refreshMachine: @escaping @MainActor (SurfaceMachineID) -> Void = { _ in }, operationController: CloudWorkspaceOperationController? = nil
) -> CloudTreeNodeActions {
@discardableResult
func run(_ label: String, _ operation: @escaping @MainActor (SurfaceCatalog) async throws -> Void) -> Task<Void, Never> {
@MainActor
func run(
_ label: String,
_ operation: @escaping @MainActor (SurfaceCatalog) async throws -> Void
) -> Task<Void, Never> {
onWillMutate(label)
return Task { @MainActor in
defer { onDidMutate() }
Expand Down Expand Up @@ -387,7 +389,6 @@ struct CloudTreeNodeActions {
actions.openRemoteTerminal = { navigation.open(machine: $0, group: $1, resource: $2, view: $3, openIn: $4) }
return actions
}

/// The local workspace's title: the remote workspace's own name — what a
/// person actually named it, or typed into its terminal — never the
/// machine's raw provider id. `hostName` (the machine's friendly label)
Expand All @@ -396,7 +397,6 @@ struct CloudTreeNodeActions {
let name = group.title.trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty ? hostName : name
}

/// The machine's friendly label — `SurfaceMachineInfo.name` (the same
/// preferred name its own sidebar row shows), never the raw provider VM
/// id. Shared by every caller that needs a machine's name in
Expand Down
4 changes: 2 additions & 2 deletions Sources/Cloud/VMClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ func formattedCloudVMHTTPError(status: Int, body: String) -> String {
Cloud VM request failed (HTTP \(status)).

What to do:
Retry the command. If it keeps failing, copy the response body and contact support.
Retry the command. If it keeps failing, copy the HTTP status and contact support.

Response body:
\(limitedSingleLine(trimmedBody.isEmpty ? "<empty>" : trimmedBody))
<unreadable response omitted>
"""
}

Expand Down
24 changes: 20 additions & 4 deletions Sources/CloudTerminalReconnectOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ final class CloudTerminalReconnectOverlayView: NSView {
private let cardView = NSVisualEffectView(frame: .zero)
private let iconView = NSImageView(frame: .zero)
private let spinner = NSProgressIndicator(frame: .zero)
private let titleLabel = NSTextField(labelWithString: "")
private let titleLabel = NSTextField(wrappingLabelWithString: "")
private let detailLabel = NSTextField(wrappingLabelWithString: "")
private let reconnectButton = NSButton(frame: .zero)
private let dismissButton = NSButton(frame: .zero)
private lazy var cardWidth = cardView.widthAnchor.constraint(equalToConstant: 360)
private(set) var currentPresentation: CloudTerminalReconnectOverlayPolicy.Presentation?

/// Creates a card whose controls are localized and hit-testable by AppKit.
Expand Down Expand Up @@ -46,6 +47,7 @@ final class CloudTerminalReconnectOverlayView: NSView {
titleLabel.alignment = .center
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
titleLabel.textColor = .labelColor
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)

detailLabel.translatesAutoresizingMaskIntoConstraints = false
detailLabel.alignment = .center
Expand Down Expand Up @@ -86,11 +88,13 @@ final class CloudTerminalReconnectOverlayView: NSView {
cardView.addSubview(stack)
cardView.addSubview(dismissButton)

// A Cloud split can be narrower than the standard card. The pane owns
// the maximum width; labels wrap within it and controls stay reachable.
updateCardWidth()
NSLayoutConstraint.activate([
cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
cardView.widthAnchor.constraint(lessThanOrEqualToConstant: 360),
cardView.widthAnchor.constraint(greaterThanOrEqualToConstant: 260),
cardWidth,
stack.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 22),
stack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -22),
stack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
Expand All @@ -99,7 +103,9 @@ final class CloudTerminalReconnectOverlayView: NSView {
iconView.heightAnchor.constraint(equalToConstant: 28),
spinner.widthAnchor.constraint(equalToConstant: 24),
spinner.heightAnchor.constraint(equalToConstant: 24),
detailLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 300),
titleLabel.widthAnchor.constraint(equalTo: stack.widthAnchor),
detailLabel.widthAnchor.constraint(equalTo: stack.widthAnchor),
reconnectButton.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
dismissButton.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 7),
dismissButton.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -7),
dismissButton.widthAnchor.constraint(equalToConstant: 22),
Expand All @@ -111,6 +117,16 @@ final class CloudTerminalReconnectOverlayView: NSView {
fatalError("init(coder:) not implemented")
}

override func layout() {
updateCardWidth()
super.layout()
}

private func updateCardWidth() {
let width = min(360, max(1, bounds.width - 24))
if cardWidth.constant != width { cardWidth.constant = width }
}

/// Routes hits to the two controls while keeping the rest of the card passive.
override func hitTest(_ point: NSPoint) -> NSView? {
guard !isHidden, alphaValue > 0 else { return nil }
Expand Down
65 changes: 57 additions & 8 deletions Sources/Mobile/Pairing/MobilePairingModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ final class MobilePairingModel {
}

/// The current render state, observed by ``MobilePairingView``.
private(set) var state: State = .loading
private(set) var state: State = .loading {
didSet { updatePreparationDeadline() }
}
/// The signed-in account email, shown in the checklist. `nil` when signed out.
private(set) var signedInEmail: String?
/// Exact iOS apps this Mac build can intentionally address.
Expand All @@ -114,6 +116,9 @@ final class MobilePairingModel {
/// refresh from several places) can't overwrite a newer result with a stale
/// ticket. Each run captures its value and bails after an `await` if superseded.
private var refreshGeneration = 0
private let preparationClock: any Clock<Duration>
private let preparationTimeout: Duration
@ObservationIgnored var preparationTimeoutTask: Task<Void, Never>?

/// Creates a pairing model.
///
Expand All @@ -126,9 +131,16 @@ final class MobilePairingModel {
/// to 600. Covers only the RPC/v1 fallback token the mint produces as a
/// side effect; the displayed Tailscale QR carries no token and never
/// expires.
init(host: MobileHostService? = nil, ticketTTL: TimeInterval = 600) {
init(
host: MobileHostService? = nil,
ticketTTL: TimeInterval = 600,
preparationClock: any Clock<Duration> = ContinuousClock(),
preparationTimeout: Duration = .seconds(30)
) {
self.host = host ?? .shared
self.ticketTTL = ticketTTL
self.preparationClock = preparationClock
self.preparationTimeout = preparationTimeout
let targetStore = MobileIOSPairingTargetStore()
iosAppTargetStore = targetStore
let targets = targetStore.availableNamespaces.map { namespace in
Expand All @@ -146,6 +158,8 @@ final class MobilePairingModel {
} ?? targets[0]
}

deinit { preparationTimeoutTask?.cancel() }

private var coordinator: AuthCoordinator? { AppDelegate.shared?.auth?.coordinator }

/// Selects one exact iOS app for legacy compatibility previews.
Expand Down Expand Up @@ -208,7 +222,7 @@ final class MobilePairingModel {
return
}
guard generation == refreshGeneration else { return }
state = Self.v2StatusTransition(status, baselineConnectionCount: status.activeConnectionCount)
receiveHostStatus(status, baselineConnectionCount: status.activeConnectionCount)
observeHostStatus()
}

Expand Down Expand Up @@ -253,6 +267,8 @@ final class MobilePairingModel {
refreshGeneration &+= 1
connectionObservationTask?.cancel()
connectionObservationTask = nil
preparationTimeoutTask?.cancel()
preparationTimeoutTask = nil
}

/// Watches the mobile host's status while the window is open and flips
Expand All @@ -272,10 +288,7 @@ final class MobilePairingModel {
self.state = .pairingDisabled
return
}
let next = Self.v2StatusTransition(status, baselineConnectionCount: baseline)
if next != self.state {
self.state = next
}
self.receiveHostStatus(status, baselineConnectionCount: baseline)
}
}
}
Expand All @@ -286,14 +299,50 @@ final class MobilePairingModel {
_ status: MobileHostServiceStatus,
baselineConnectionCount: Int
) -> State {
guard status.isRunning, status.isPairingReady else { return .preparing }
guard status.isRunning else { return .failed(preparationFailureMessage) }
guard status.isPairingReady else {
return status.lastErrorDescription?.isEmpty == false ? .failed(preparationFailureMessage) : .preparing
}
let ready = State.ready(Ready(
attachURL: "", tailscaleLines: [], manualEntry: nil,
reachableViaIroh: true, v2Only: true
))
return status.activeConnectionCount > baselineConnectionCount ? .connected(from: ready) : ready
}

/// A retry starts in `refresh`; repeated pending status cannot hide an error.
func receiveHostStatus(_ status: MobileHostServiceStatus, baselineConnectionCount: Int) {
let next = Self.v2StatusTransition(status, baselineConnectionCount: baselineConnectionCount)
if case .failed = state, next == .preparing { return }
if next != state { state = next }
}

private static var preparationFailureMessage: String {
String(localized: "mobile.pairing.error.preparationFailed",
defaultValue: "Pairing could not finish. Check your connection and try again.")
}

private func updatePreparationDeadline() {
guard state == .preparing else {
preparationTimeoutTask?.cancel()
preparationTimeoutTask = nil
return
}
guard preparationTimeoutTask == nil else { return }
let clock = preparationClock
let timeout = preparationTimeout
let generation = refreshGeneration
// This deadline bounds the visible preparing state, including a bound
// endpoint whose authenticated registration has not completed.
preparationTimeoutTask = Task { @MainActor [weak self, clock] in
do { try await clock.sleep(for: timeout) } catch { return }
guard !Task.isCancelled, let self, self.refreshGeneration == generation,
self.state == .preparing else { return }
self.preparationTimeoutTask = nil
self.state = .failed(Self.preparationFailureMessage)
}
}

/// Computes the next render state from a host status event. Pure, so the
/// transitions are unit tested without a live host.
///
Expand Down
Loading