fix(macos): attach the onboarding CLI install prompt to the window (#128183)

* fix(macos): attach the onboarding CLI install prompt to the window

On an unreleased build, the onboarding CLI page resolved its install target
through NSAlert.runModal() — a detached app-modal panel that is absent from
the app's AX window list and freely covered by system permission dialogs.
Live repro: with two TCC prompts stacked over it, the main thread sat parked
in runModal for 30+ minutes while the page showed an active "Install
OpenClaw" spinner, the close button was disabled, and busyReason claimed an
install was in flight — before the user had chosen anything. A spinner that
means "answer a dialog you cannot see" is a silent-failure trap.

The prompt (both the confirm-stable alert and the channel chooser) is now a
sheet attached to the onboarding window via beginSheetModal, so it stays
z-ordered with the window and AX-visible. The busy state is honest: a new
.choosingTarget phase renders the install row as pending instead of running,
and installingCLI/close-disabled/busyReason only engage after a target is
actually chosen. Non-onboarding callers (checkAndPromptIfNeeded) keep
runModal by passing no window. All alert text and button order unchanged.

* style(macos): wrap sheetPresentationWindow property body
This commit is contained in:
Peter Steinberger
2026-08-23 04:09:08 -07:00
committed by GitHub
parent cbc89fc5a1
commit 47436e447e
4 changed files with 35 additions and 12 deletions
@@ -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,
@@ -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
}
@@ -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
}
@@ -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 }