diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index 4957245138f4..c503d5a9efcc 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -60,14 +60,17 @@ final class CLIInstallPrompter { guard lastPrompt != version else { return } AppDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) - if let target = self.installTargetForCurrentBuild(confirmStable: true) { + if let target = await self.installTargetForCurrentBuild(confirmStable: true, presentingSheetOn: nil) { Task { _ = await self.installCLI(target: target) } } self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") } - func installTargetForCurrentBuild(confirmStable: Bool = false) -> CLIInstaller.InstallTarget? { + func installTargetForCurrentBuild( + confirmStable: Bool = false, + presentingSheetOn window: NSWindow?) async -> CLIInstaller.InstallTarget? + { let appVersion = Self.appVersion() if let target = CLIInstaller.automaticInstallTarget( appVersion: appVersion, @@ -80,7 +83,7 @@ final class CLIInstallPrompter { alert.addButton(withTitle: "Install CLI") alert.addButton(withTitle: "Not Now") alert.addButton(withTitle: "Open Settings") - switch alert.runModal() { + switch await self.present(alert, presentingSheetOn: window) { case .alertFirstButtonReturn: return target case .alertThirdButtonReturn: @@ -91,14 +94,18 @@ final class CLIInstallPrompter { } } - return self.chooseChannel( + return await self.chooseChannel( suggested: CLIInstaller.suggestedChannel( appVersion: appVersion, - isDebug: CLIInstallBuild.isDebug)) + isDebug: CLIInstallBuild.isDebug), + presentingSheetOn: window) .map(CLIInstaller.InstallTarget.channel) } - private func chooseChannel(suggested: CLIInstaller.Channel) -> CLIInstaller.Channel? { + private func chooseChannel( + suggested: CLIInstaller.Channel, + presentingSheetOn window: NSWindow?) async -> CLIInstaller.Channel? + { let channels = [suggested] + CLIInstaller.Channel.allCases.filter { $0 != suggested } let alert = NSAlert() alert.messageText = "Choose OpenClaw CLI channel" @@ -111,12 +118,18 @@ final class CLIInstallPrompter { alert.addButton(withTitle: channel.label) } alert.addButton(withTitle: "Not Now") - let response = alert.runModal() + let response = await self.present(alert, presentingSheetOn: window) let index = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue guard channels.indices.contains(index) else { return nil } return channels[index] } + private func present(_ alert: NSAlert, presentingSheetOn window: NSWindow?) async -> NSApplication.ModalResponse { + // Attaching onboarding alerts preserves their AX visibility and window-relative z-order. + guard let window else { return alert.runModal() } + return await alert.beginSheetModal(for: window) + } + private func installCLI( target: CLIInstaller.InstallTarget, showCompletionAlert: Bool = true, diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 02c729ef3916..5bc7db526502 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -539,6 +539,10 @@ final class OnboardingController: NSObject, NSWindowDelegate { static let shared = OnboardingController() static let windowStyleMask: NSWindow.StyleMask = [.titled, .closable, .resizable, .fullSizeContentView] private var window: NSWindow? + var sheetPresentationWindow: NSWindow? { + self.window + } + /// Human description of work in flight ("Installing the Gateway…"). /// While set, closing the window asks for confirmation instead of quitting /// setup mid-operation. @@ -638,6 +642,7 @@ final class OnboardingController: NSObject, NSWindowDelegate { struct OnboardingView: View { enum CLIInstallPhase { case idle + case choosingTarget case installing case startingService } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index 2e99e80098c5..8b8e5c2fed31 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -146,9 +146,6 @@ extension OnboardingView { func startCLIInstall() { guard self.onboardingVisible, !installingCLI else { return } installingCLI = true - OnboardingController.shared.setWindowCloseEnabled(false) - // Cmd-W bypasses the disabled close button; the delegate asks first. - OnboardingController.shared.busyReason = "OpenClaw is installing the Gateway service." Task { @MainActor in await self.runCLIInstall() } } @@ -159,17 +156,24 @@ extension OnboardingView { } func runCLIInstall() async { - self.cliInstallPhase = .installing + self.cliInstallPhase = .choosingTarget defer { self.installingCLI = false self.cliInstallPhase = .idle OnboardingController.shared.setWindowCloseEnabled(true) OnboardingController.shared.busyReason = nil } - guard let target = CLIInstallPrompter.shared.installTargetForCurrentBuild() else { + // Choosing a target is not installation: keep its spinner and close/busy guards inactive. + guard let target = await CLIInstallPrompter.shared.installTargetForCurrentBuild( + presentingSheetOn: OnboardingController.shared.sheetPresentationWindow) + else { cliStatus = "CLI installation cancelled." return } + self.cliInstallPhase = .installing + OnboardingController.shared.setWindowCloseEnabled(false) + // Cmd-W bypasses the disabled close button; the delegate asks first. + OnboardingController.shared.busyReason = "OpenClaw is installing the Gateway service." let installed = await CLIInstaller.install(target: target) { message in self.cliStatus = message } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 741c8e3a2ce9..cb59d4631a01 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -879,6 +879,7 @@ extension OnboardingView { private var installStepStateForInstall: InstallStepState { if self.cliInstalled { return .done } if self.installingCLI { + if self.cliInstallPhase == .choosingTarget { return .pending } return self.cliInstallPhase == .startingService ? .done : .running } if self.installFailed { return .failed }