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
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,26 @@ public struct QuitConfirmationStore: Sendable {

/// Whether the quit flow should show the confirmation dialog.
///
/// Semantics are kept verbatim from the legacy `QuitWarningSettings`
/// namespace: a prior in-session confirmation always skips the dialog,
/// dev builds never warn, and otherwise ``confirmQuitMode`` decides
/// (`dirtyOnly` consults `hasDirtyWorkspaces`).
/// A logout, restart, or shutdown never shows the dialog: it would block
/// the whole session change. Otherwise semantics are kept verbatim from
/// the legacy `QuitWarningSettings` namespace: a prior in-session
/// confirmation always skips the dialog, dev builds never warn, and
/// ``confirmQuitMode`` decides (`dirtyOnly` consults `hasDirtyWorkspaces`).
///
/// - Parameters:
/// - isQuitWarningConfirmed: Whether the user already confirmed the
/// quit warning earlier in this terminate flow.
/// - hasDirtyWorkspaces: Whether any workspace has unsaved/dirty state.
/// - isDevBuild: Whether this is a dev-flavor build (the app resolves
/// its build flavor; dev builds skip the warning entirely).
/// - quitReason: Why the app is quitting; see ``QuitRequestReason``.
public func shouldShowConfirmation(
isQuitWarningConfirmed: Bool,
hasDirtyWorkspaces: Bool,
isDevBuild: Bool
isDevBuild: Bool,
quitReason: QuitRequestReason = .user
) -> Bool {
guard quitReason != .sessionEnd else { return false }
guard !isQuitWarningConfirmed else { return false }
guard !isDevBuild else { return false }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import CoreServices

/// Why the app was asked to quit, read from the quit Apple Event.
///
/// macOS sends logout, restart, and shutdown as a quit event whose
/// `kAEQuitReason` attribute names the session change. A confirmation dialog
/// shown then blocks the whole session change, so those quits skip it.
public enum QuitRequestReason: Sendable, Equatable {
/// Cmd+Q, the menu, the Dock, or any quit without a session-change reason.
case user
/// Logout, restart, or shutdown started by the system.
case sessionEnd

/// Maps the quit event's `kAEQuitReason` code. A missing or unknown code
/// is an ordinary quit.
public init(appleEventQuitReason code: OSType?) {
switch code {
case kAELogOut, kAEReallyLogOut,
kAEShowRestartDialog, kAERestart,
kAEShowShutdownDialog, kAEShutDown:
self = .sessionEnd
default:
self = .user
}
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import CoreServices
import Foundation
import Testing
@testable import CmuxSettings
Expand Down Expand Up @@ -477,6 +478,38 @@ struct QuitConfirmationPolicyTests {
))
}

/// A confirmation dialog during logout, restart, or shutdown blocks the
/// whole session change, so those quits skip it in every mode.
@Test(arguments: ["always", "dirty-only"])
func sessionEndNeverWarns(mode: String) {
let defaults = makeScratchDefaults()
defaults.set(mode, forKey: "confirmQuit")
let store = QuitConfirmationStore(defaults: defaults)

#expect(!store.shouldShowConfirmation(
isQuitWarningConfirmed: false, hasDirtyWorkspaces: true, isDevBuild: false,
quitReason: .sessionEnd
))
#expect(store.shouldShowConfirmation(
isQuitWarningConfirmed: false, hasDirtyWorkspaces: true, isDevBuild: false,
quitReason: .user
))
}

@Test func quitEventReasonMapsSessionChanges() {
let sessionCodes: [OSType] = [
kAELogOut, kAEReallyLogOut,
kAEShowRestartDialog, kAERestart,
kAEShowShutdownDialog, kAEShutDown,
]
for code in sessionCodes {
#expect(QuitRequestReason(appleEventQuitReason: code) == .sessionEnd)
}
#expect(QuitRequestReason(appleEventQuitReason: nil) == .user)
#expect(QuitRequestReason(appleEventQuitReason: 0) == .user)
#expect(QuitRequestReason(appleEventQuitReason: kAEQuitApplication) == .user)
}

@Test func neverModeNeverWarns() {
let defaults = makeScratchDefaults()
defaults.set("never", forKey: "confirmQuit")
Expand Down
9 changes: 6 additions & 3 deletions Sources/App/ComputerUseOnboardingCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import CmuxComputerUse
@MainActor
final class ComputerUseOnboardingCoordinator {
typealias StartingPoint = ComputerUseOnboardingWindowController.StartingPoint
typealias Presenter = @MainActor (StartingPoint) -> Void
typealias Origin = ComputerUseOnboardingWindowController.PresentationOrigin
typealias Presenter = @MainActor (StartingPoint, Origin) -> Void

private let runtimeService: ComputerUseRuntimeService
private let presenter: Presenter
Expand All @@ -24,17 +25,19 @@ final class ComputerUseOnboardingCoordinator {
@discardableResult
func requestFromSettings(startingAt startingPoint: StartingPoint) -> Bool {
runtimeService.onboardingWasPresented()
presenter(startingPoint)
presenter(startingPoint, .userAction)
return true
}

/// Claims and presents first-use setup atomically on the main actor. A ready
/// helper or an already claimed flow stays quiet, including after dismissal.
/// Call only for authenticated, locally owned functional CUA requests.
/// An agent's tool call is not a user action, so the window must not take
/// focus from the app the user is working in.
@discardableResult
func requestFromToolInvocation() -> Bool {
guard runtimeService.requestAutomaticOnboarding() else { return false }
presenter(.overview)
presenter(.overview, .toolInvocation)
return true
}
}
76 changes: 72 additions & 4 deletions Sources/App/ComputerUseOnboardingWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,36 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate {
}
}

/// Who asked for the window. Only a deliberate user action may take focus
/// away from whatever app the user is working in.
enum PresentationOrigin: Sendable, Equatable {
case userAction
case toolInvocation
}

enum ActivationPlan: Sendable, Equatable {
/// Activate cmux and make the window key.
case activateAndFocus
/// cmux is already active: show the window without taking key focus
/// from the terminal the user is typing in.
case orderFrontWithoutFocus
/// Another app is active: keep the window behind it, bounce the Dock
/// icon, and bring the window forward once the user switches to cmux.
case waitForAppActivation
}

nonisolated static func activationPlan(
origin: PresentationOrigin,
isAppActive: Bool
) -> ActivationPlan {
switch origin {
case .userAction:
return .activateAndFocus
case .toolInvocation:
return isAppActive ? .orderFrontWithoutFocus : .waitForAppActivation
}
}

static let seenDefaultsKey = "cmux.computerUse.onboarding.seen"
static let directCaptureReadyDefaultsKey = ComputerUseOnboardingStore.legacyCompletionKey
nonisolated static let completionDismissDelay: Duration = .seconds(2.4)
Expand All @@ -122,6 +152,7 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate {
private var pendingPermissionStep: ComputerUseOnboardingStep?
private var presentationState: ComputerUseOnboardingPresentationState?
private var completionDismissTask: Task<Void, Never>?
private var appActivationObserver: NSObjectProtocol?

init(
runtimeService: ComputerUseRuntimeService,
Expand Down Expand Up @@ -156,7 +187,11 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate {
(window?.isVisible ?? false) || (permissionCompanionWindow?.isVisible ?? false)
}

func present(startingAt startingPoint: StartingPoint = .overview) {
func present(
startingAt startingPoint: StartingPoint = .overview,
origin: PresentationOrigin = .userAction
) {
stopWaitingForAppActivation()
stopSystemSettingsObservation()
completionDismissTask?.cancel()
completionDismissTask = nil
Expand All @@ -168,9 +203,40 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate {
window.level = .normal
window.collectionBehavior = [.managed]
window.hidesOnDeactivate = false
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
show(window, plan: Self.activationPlan(origin: origin, isAppActive: NSApp.isActive))
}

private func show(_ window: NSWindow, plan: ActivationPlan) {
switch plan {
case .activateAndFocus:
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
window.orderFrontRegardless()
case .orderFrontWithoutFocus:
window.orderFront(nil)
case .waitForAppActivation:
window.orderBack(nil)
NSApp.requestUserAttention(.informationalRequest)
appActivationObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.stopWaitingForAppActivation()
guard let window = self.window, window.isVisible else { return }
window.orderFront(nil)
}
}
}
}

private func stopWaitingForAppActivation() {
if let appActivationObserver {
NotificationCenter.default.removeObserver(appActivationObserver)
}
appActivationObserver = nil
}

func makeWindow(startingAt startingPoint: StartingPoint = .overview) -> ComputerUseOnboardingWindow {
Expand Down Expand Up @@ -224,6 +290,7 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate {
}

func dismiss() {
stopWaitingForAppActivation()
stopSystemSettingsObservation()
completionDismissTask?.cancel()
completionDismissTask = nil
Expand All @@ -236,6 +303,7 @@ final class ComputerUseOnboardingWindowController: NSObject, NSWindowDelegate {
guard let closingWindow = notification.object as? NSWindow,
closingWindow === window
else { return }
stopWaitingForAppActivation()
stopSystemSettingsObservation()
dismissPermissionCompanion()
closingWindow.delegate = nil
Expand Down
9 changes: 5 additions & 4 deletions Sources/App/ComputerUseUXCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,15 @@ final class ComputerUseUXCoordinator {
}

private func presentOnboardingWindow(
startingAt startingPoint: ComputerUseOnboardingWindowController.StartingPoint
startingAt startingPoint: ComputerUseOnboardingWindowController.StartingPoint,
origin: ComputerUseOnboardingWindowController.PresentationOrigin
) {
userDefaults.set(true, forKey: ComputerUseOnboardingWindowController.seenDefaultsKey)
let controller = onboardingWindowController ?? ComputerUseOnboardingWindowController(
runtimeService: runtimeService
)
onboardingWindowController = controller
controller.present(startingAt: startingPoint)
controller.present(startingAt: startingPoint, origin: origin)
}

private func ensureOnboardingCoordinator() -> ComputerUseOnboardingCoordinator {
Expand All @@ -324,8 +325,8 @@ final class ComputerUseUXCoordinator {
}
let coordinator = ComputerUseOnboardingCoordinator(
runtimeService: runtimeService,
presenter: { [weak self] startingPoint in
self?.presentOnboardingWindow(startingAt: startingPoint)
presenter: { [weak self] startingPoint, origin in
self?.presentOnboardingWindow(startingAt: startingPoint, origin: origin)
}
)
onboardingCoordinator = coordinator
Expand Down
21 changes: 19 additions & 2 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2313,13 +2313,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let quitConfirmationStore = QuitConfirmationStore(defaults: .standard)
let hasDirtyWorkspaces = hasQuitConfirmationDirtyWorkspaces()
let confirmQuitMode = quitConfirmationStore.confirmQuitMode
let quitReason = Self.currentQuitRequestReason()

StartupBreadcrumbLog.append(
"appDelegate.shouldTerminate.begin",
fields: [
"buildFlavor": buildFlavor.rawValue,
"confirmQuitMode": confirmQuitMode.rawValue,
"hasDirtyWorkspaces": hasDirtyWorkspaces ? "1" : "0",
"quitReason": quitReason == .sessionEnd ? "sessionEnd" : "user",
"quitWarningConfirmed": isQuitWarningConfirmed ? "1" : "0",
"quitWarningEnabled": quitConfirmationStore.isEnabled ? "1" : "0"
]
Expand All @@ -2330,12 +2332,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if !quitConfirmationStore.shouldShowConfirmation(
isQuitWarningConfirmed: isQuitWarningConfirmed,
hasDirtyWorkspaces: hasDirtyWorkspaces,
isDevBuild: buildFlavor == .dev
isDevBuild: buildFlavor == .dev,
quitReason: quitReason
) {
prepareForConfirmedAppTermination()
closeAllWebInspectorsBeforeAppTeardown()
let reason: String
if isQuitWarningConfirmed {
if quitReason == .sessionEnd {
reason = "sessionEnd"
} else if isQuitWarningConfirmed {
reason = "confirmed"
} else if buildFlavor == .dev {
reason = "devBuild"
Expand Down Expand Up @@ -2364,6 +2369,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return .terminateLater
}

/// Reads `kAEQuitReason` from the quit Apple Event AppKit is handling.
/// Logout, restart, and shutdown set it; Cmd+Q and the menu do not.
private static func currentQuitRequestReason() -> QuitRequestReason {
guard let descriptor = NSAppleEventManager.shared().currentAppleEvent?
.attributeDescriptor(forKeyword: AEKeyword(kAEQuitReason)) else {
return .user
}
// loginwindow sends the reason as typeType; accept typeEnumerated too.
let code = descriptor.typeCodeValue != 0 ? descriptor.typeCodeValue : descriptor.enumCodeValue
return QuitRequestReason(appleEventQuitReason: code)
}

@discardableResult
private func closeAllWebInspectorsBeforeAppTeardown() -> Int {
WebViewInspectorTeardown.closeAllInspectors(in: NSApp.windows)
Expand Down
14 changes: 13 additions & 1 deletion Sources/TerminalNotificationDeliveryDecision.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable {
return Self(disposition: .externalDelivery, effects: effects)
}

var focusedEffects = effects
var focusedEffects = effects.keepingFocusedWorkspaceInPlace(isFocusedPane: true)
// The active surface is already visible. Preserve history/unread and
// the custom automation hook while suppressing external feedback.
focusedEffects.desktop = false
Expand All @@ -29,3 +29,15 @@ struct TerminalNotificationDeliveryDecision: Equatable, Sendable {
return Self(disposition: .focusedInline, effects: focusedEffects)
}
}

extension TerminalNotificationPolicyEffects {
/// A notification for the pane the user is looking at must not move the
/// workspace they are typing in. Both the terminal notification store and
/// Feed's delivery lane apply this before sidebar ordering.
func keepingFocusedWorkspaceInPlace(isFocusedPane: Bool) -> Self {
guard isFocusedPane else { return self }
var effects = self
effects.reorderWorkspace = false
return effects
}
}
3 changes: 3 additions & 0 deletions Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1514,6 +1514,9 @@ final class TerminalNotificationStore: ObservableObject {
tabId: request.tabId,
surfaceId: request.surfaceId
)
let effects = effects.keepingFocusedWorkspaceInPlace(
isFocusedPane: shouldSuppressExternalDelivery
)
let notification = TerminalNotification(
id: notificationID,
tabId: request.tabId,
Expand Down
Loading
Loading