Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 10 additions & 10 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
33857 CLI/cmux.swift
17914 Sources/AppDelegate.swift
16740 Sources/ContentView.swift
16709 Sources/ContentView.swift
14612 Sources/TerminalController.swift
13595 Sources/Panels/BrowserPanel.swift
12088 Sources/GhosttyTerminalView.swift
Expand All @@ -13,15 +13,15 @@
7911 Sources/Panels/BrowserPanelView.swift
7350 cmuxTests/WorkspaceUnitTests.swift
6944 cmuxTests/WorkspaceRemoteConnectionTests.swift
6363 cmuxTests/GhosttyConfigTests.swift
6317 cmuxTests/SessionPersistenceTests.swift
6299 cmuxTests/GhosttyConfigTests.swift
6153 CLI/cmux_open.swift
6074 Sources/TabManager.swift
6074 Sources/TextBoxInput.swift
5925 cmuxTests/TerminalAndGhosttyTests.swift
5522 cmuxTests/BrowserConfigTests.swift
4921 Sources/cmuxApp.swift
5113 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
4921 Sources/cmuxApp.swift
4460 Sources/Panels/FilePreviewPanel.swift
4400 cmuxTests/BrowserPanelTests.swift
4227 Sources/BrowserWindowPortal.swift
Expand All @@ -32,7 +32,7 @@
3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3397 Sources/CmuxConfig.swift
3331 cmuxTests/TabManagerSessionSnapshotTests.swift
3204 Sources/Update/UpdateTitlebarAccessory.swift
3200 Sources/Update/UpdateTitlebarAccessory.swift
2878 Sources/SessionIndexView.swift
2871 cmuxTests/CMUXOpenCommandTests.swift
2573 Sources/KeyboardShortcutSettings.swift
Expand All @@ -59,11 +59,11 @@
1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift
1574 cmuxTests/MarkdownPanelTests.swift
1560 cmuxTests/TextBoxMentionCompletionTests.swift
1498 cmuxTests/OmnibarAndToolsTests.swift
1497 cmuxTests/OmnibarAndToolsTests.swift
1496 cmuxUITests/MultiWindowNotificationsUITests.swift
1446 Sources/FileExplorerStore.swift
1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift
1382 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift
1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift
1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift
1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift
1362 Sources/CMUXInstalledExtensionSidebarHostView.swift
Expand Down Expand Up @@ -92,9 +92,9 @@
937 Sources/TextBoxMentionIndexStore.swift
934 Sources/App/ShortcutRoutingSupport.swift
926 Sources/DockPanelView.swift
920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift
919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift
918 cmuxTests/WorkspaceGroupTests.swift
920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift
905 Sources/CmuxSSHURLRequest.swift
901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift
893 Sources/WorkspaceContentView.swift
Expand All @@ -117,13 +117,14 @@
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
746 Sources/App/MenuBarExtraController.swift
738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
736 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift
716 Sources/TaskManagerSnapshot.swift
715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
715 Sources/AppleScriptSupport.swift
710 Sources/TerminalSSHSessionDetector.swift
706 CLI/CMUXCLI+Config.swift
707 CLI/CMUXCLI+AgentHookDefinitions.swift
706 CLI/CMUXCLI+Config.swift
699 Sources/RightSidebarPanelView.swift
699 cmuxTests/TerminalNotificationClearAllTests.swift
698 cmuxTests/RestorableAgentHookProviderResumeTests.swift
Expand Down Expand Up @@ -187,7 +188,6 @@
528 cmuxTests/CLINotifyProcessTestSupport.swift
528 cmuxUITests/AutomationSocketUITests.swift
527 CLI/CLISocketPathResolver.swift
726 cmuxTests/CLICodexHookTimeoutRegressionTests.swift
523 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift
520 CLI/CMUXCLI+AmpExtension.swift
520 cmuxTests/MainWindowVisibilityControllerTests.swift
Expand Down
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,17 @@ App path:
**Claude Code** outputs:

```markdown
=======================================================
-------------------------------------------------------
[cmux DEV my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app)
=======================================================
-------------------------------------------------------
```

**Codex** outputs:

```markdown
=======================================================
-------------------------------------------------------
[my-tag: file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app)
=======================================================
-------------------------------------------------------
```

Never use `/tmp/cmux-<tag>/...` app links in chat output.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public final class HostBrowserSignInFlow {
/// Whether a browser sign-in attempt (popup + completion) is in flight.
public private(set) var isSigningIn = false

/// Whether the in-flight sign-in attempt has been waiting on the hosted
/// (Safari-backed) `ASWebAuthenticationSession` longer than
/// ``slowSignInThreshold`` without delivering a callback. The Settings
/// account UI watches this to offer an "open sign-in in your default
/// browser" fallback when the system sign-in window hangs (issue #6015),
/// instead of leaving the user on an indefinite spinner. Resets to `false`
/// whenever an attempt completes, is cancelled, or is replaced.
public private(set) var signInIsSlow = false

private let coordinator: AuthCoordinator
private let tokenStore: any StackAuthTokenStoreProtocol
private let sessionFactory: any HostBrowserAuthSessionFactory
Expand All @@ -24,16 +33,19 @@ public final class HostBrowserSignInFlow {
private let callbackScheme: @MainActor () -> String
private let clock: any Clock<Duration>
private let browserAttemptTimeout: TimeInterval
private let slowSignInThreshold: TimeInterval
private let log = AuthDebugLog()

@ObservationIgnored private var activeSession: (any HostBrowserAuthSession)?
@ObservationIgnored private var activeSessionContinuation: CheckedContinuation<URL?, Never>?
@ObservationIgnored private var activeSessionContinuationAttemptID: UInt64?
@ObservationIgnored private var activeAttemptTimeoutTask: Task<Void, Never>?
@ObservationIgnored private var slowSignInHintTask: Task<Void, Never>?
@ObservationIgnored private var nextAttemptID: UInt64 = 0
@ObservationIgnored private var activeAttemptID: UInt64?
@ObservationIgnored private var activeCallbackState: String?
@ObservationIgnored private var pendingManualCallbackState: String?
@ObservationIgnored private var pendingFallbackCallbackState: String?
@ObservationIgnored private var signOutGeneration: UInt64 = 0

/// Creates the flow.
Expand All @@ -49,6 +61,9 @@ public final class HostBrowserSignInFlow {
/// - callbackScheme: The custom callback scheme for the popup.
/// - clock: Drives the sign-in deadline; tests inject a virtual clock.
/// - browserAttemptTimeout: Cancels abandoned external-browser attempts.
/// - slowSignInThreshold: How long an attempt may wait on the hosted
/// browser before ``signInIsSlow`` flips to surface the manual
/// default-browser fallback. `0` disables the hint.
public init(
coordinator: AuthCoordinator,
tokenStore: any StackAuthTokenStoreProtocol,
Expand All @@ -57,7 +72,8 @@ public final class HostBrowserSignInFlow {
makeSignInURL: @escaping @MainActor (_ callbackState: String) -> URL,
callbackScheme: @escaping @MainActor () -> String,
clock: any Clock<Duration> = ContinuousClock(),
browserAttemptTimeout: TimeInterval = 10 * 60
browserAttemptTimeout: TimeInterval = 10 * 60,
slowSignInThreshold: TimeInterval = 30
) {
self.coordinator = coordinator
self.tokenStore = tokenStore
Expand All @@ -67,6 +83,7 @@ public final class HostBrowserSignInFlow {
self.callbackScheme = callbackScheme
self.clock = clock
self.browserAttemptTimeout = browserAttemptTimeout
self.slowSignInThreshold = slowSignInThreshold
}

/// Start a browser sign-in without awaiting the result (Settings button).
Expand All @@ -84,6 +101,19 @@ public final class HostBrowserSignInFlow {
return makeSignInURL(state)
}

/// The hosted sign-in URL for the in-flight attempt, to open in the user's
/// real default browser when the hosted-browser popup hangs (issue #6015).
/// Reuses the active attempt's callback state, so the resulting
/// `cmux://auth-callback` deep link routes back into the in-flight attempt
/// through ``handleCallbackURL(_:)`` instead of being rejected as a
/// stateful callback with no matching attempt. `nil` when no attempt is in
/// flight.
public var activeAttemptSignInURL: URL? {
guard let activeCallbackState else { return nil }
pendingFallbackCallbackState = activeCallbackState
return makeSignInURL(activeCallbackState)
}

/// Run a browser sign-in attempt with a deadline, for the socket
/// `auth.begin_sign_in` command. Returns whether the app ended signed in
/// before the deadline; the popup itself stays up past the deadline so the
Expand Down Expand Up @@ -114,6 +144,7 @@ public final class HostBrowserSignInFlow {
}
log.log("auth.callback.external.routeToActive attempt=\(attemptID)")
cancelAttemptTimeout()
cancelSlowSignInHint()
let signedIn = await completeCallback(url: url, attemptID: attemptID)
resumeActiveSessionContinuation(
returning: nil,
Expand All @@ -126,6 +157,13 @@ public final class HostBrowserSignInFlow {
log.log("auth.callback.external.routeToFallback")
return await completeCallback(url: url, attemptID: nil)
}
if callbackRouter.isAuthCallbackURL(url),
let state = callbackState(from: url),
state == pendingFallbackCallbackState {
log.log("auth.callback.external.routeToIssuedFallback")
pendingFallbackCallbackState = nil
return await completeCallback(url: url, attemptID: nil, acceptedExternalState: state)
}
log.log("auth.callback.external.reject reason=noActiveAttempt")
return false
}
Expand Down Expand Up @@ -199,6 +237,7 @@ public final class HostBrowserSignInFlow {
isSigningIn = true
log.log("auth.browser.attempt.start id=\(attemptID) generation=\(signOutGeneration) state=\(redactedState(callbackState))")
scheduleAttemptTimeout(attemptID)
scheduleSlowSignInHint(attemptID)
return Task { @MainActor [weak self] in
guard let self else { return false }
defer { self.finishAttempt(attemptID) }
Expand All @@ -209,6 +248,7 @@ public final class HostBrowserSignInFlow {
}
guard self.activeAttemptID == attemptID else { return false }
self.cancelAttemptTimeout()
self.cancelSlowSignInHint()
return await self.completeCallback(url: callbackURL, attemptID: attemptID)
}
}
Expand Down Expand Up @@ -267,6 +307,7 @@ public final class HostBrowserSignInFlow {
expectedAttemptID: attemptID
)
cancelAttemptTimeout()
cancelSlowSignInHint()
activeAttemptID = nil
activeCallbackState = nil
activeSession = nil
Expand All @@ -279,8 +320,10 @@ public final class HostBrowserSignInFlow {
}
resumeActiveSessionContinuation(returning: nil, reason: "cancelAttempt")
cancelAttemptTimeout()
cancelSlowSignInHint()
activeAttemptID = nil
activeCallbackState = nil
pendingFallbackCallbackState = nil
activeSession?.cancel()
activeSession = nil
isSigningIn = false
Expand All @@ -307,11 +350,37 @@ public final class HostBrowserSignInFlow {
activeAttemptTimeoutTask = nil
}

/// After ``slowSignInThreshold`` of an attempt still waiting on the hosted
/// browser, flip ``signInIsSlow`` so the account UI can offer the manual
/// default-browser fallback. Non-destructive: the popup keeps running, so a
/// user who is simply taking their time can still finish in it.
private func scheduleSlowSignInHint(_ attemptID: UInt64) {
slowSignInHintTask?.cancel()
guard slowSignInThreshold > 0 else {
slowSignInHintTask = nil
return
}
let threshold = slowSignInThreshold
let clock = self.clock
slowSignInHintTask = Task { @MainActor [weak self] in
try? await clock.sleep(for: .seconds(threshold))
guard !Task.isCancelled, let self, self.activeAttemptID == attemptID else { return }
self.log.log("auth.browser.attempt.slow id=\(attemptID)")
self.signInIsSlow = true
}
}

private func cancelSlowSignInHint() {
slowSignInHintTask?.cancel()
slowSignInHintTask = nil
signInIsSlow = false
}

// MARK: - Callback completion

/// Seed the callback tokens and publish the session through the shared
/// coordinator, guarding against a sign-out racing the round trip.
private func completeCallback(url: URL, attemptID: UInt64?) async -> Bool {
private func completeCallback(url: URL, attemptID: UInt64?, acceptedExternalState: String? = nil) async -> Bool {
log.log("auth.callback.complete.begin attempt=\(attemptID.map(String.init) ?? "external") \(authCallbackSummary(url))")
guard let payload = callbackRouter.callbackPayload(from: url) else {
log.log("auth.callback rejected: invalid payload")
Expand All @@ -322,7 +391,7 @@ public final class HostBrowserSignInFlow {
log.log("auth.callback rejected: state mismatch attempt=\(attemptID)")
return false
}
} else if callbackState(from: url) != nil {
} else if let state = callbackState(from: url), state != acceptedExternalState {
log.log("auth.callback rejected: stateful external callback without active attempt")
return false
}
Expand Down Expand Up @@ -374,6 +443,9 @@ public final class HostBrowserSignInFlow {
)
return false
}
if callbackState(from: url) == pendingFallbackCallbackState {
pendingFallbackCallbackState = nil
}
return true
}

Expand Down
Loading
Loading