diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 0932f329bdde..e1cc20860119 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -31195,7 +31195,7 @@ }, { "kind": "conditional-branch", - "line": 688, + "line": 704, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Finish", "surface": "apple", @@ -31203,7 +31203,7 @@ }, { "kind": "conditional-branch", - "line": 688, + "line": 704, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Next", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 7078a5a763a2..77a33f3da40d 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -514,22 +514,38 @@ final class OnboardingController: NSObject, NSWindowDelegate { let hosting = NSHostingController(rootView: OnboardingView()) let window = NSWindow(contentViewController: hosting) window.title = UIStrings.welcomeTitle - window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) window.styleMask = Self.windowStyleMask + window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) + if let visibleFrame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame { + // Constrain the full window frame, not only its content. Otherwise the + // navigation bar can land below the Dock on shorter displays. + window.setFrame(Self.initialWindowFrame(visibleFrame: visibleFrame), display: false) + } else { + window.center() + } // Keep the focused dialog width while letting taller displays give setup more breathing room. - window.contentMinSize = NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight) + window.contentMinSize = NSSize( + width: OnboardingView.windowWidth, + height: OnboardingView.minimumWindowHeight) window.contentMaxSize = NSSize(width: OnboardingView.windowWidth, height: .greatestFiniteMagnitude) window.titlebarAppearsTransparent = true window.titleVisibility = .hidden window.isMovableByWindowBackground = true window.delegate = self - window.center() DockIconManager.shared.temporarilyShowDock() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) self.window = window } + static func initialWindowFrame(visibleFrame: NSRect) -> NSRect { + let contentRect = NSRect( + origin: .zero, + size: NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) + let preferredFrame = NSWindow.frameRect(forContentRect: contentRect, styleMask: self.windowStyleMask) + return WindowPlacement.centeredFrame(size: preferredFrame.size, in: visibleFrame) + } + func close() { self.busyReason = nil self.window?.close() @@ -607,6 +623,7 @@ struct OnboardingView: View { static let windowWidth: CGFloat = 630 static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content + static let minimumWindowHeight: CGFloat = 520 let pageWidth: CGFloat = Self.windowWidth let connectionPageIndex = 1 @@ -625,16 +642,15 @@ struct OnboardingView: View { 130 } - /// The baseline fits every setup control. Taller windows donate all extra room - /// to the active page instead of leaving the content pinned to a fixed canvas. + /// The active page is scrollable on short screens. Taller windows donate all + /// extra room instead of leaving the content pinned to a fixed canvas. func contentHeight(for windowHeight: CGFloat) -> CGFloat { Self.contentHeight(for: windowHeight, usesCompactHero: self.usesCompactHero) } static func contentHeight(for windowHeight: CGFloat, usesCompactHero: Bool) -> CGFloat { - let availableHeight = max(Self.windowHeight, windowHeight) let heroHeight: CGFloat = usesCompactHero ? 78 : 145 - return availableHeight - heroHeight - 72 + return max(0, windowHeight - heroHeight - 72) } static func pageOrder( diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index 54b1e2ac3706..7aa19bbd69f1 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -42,7 +42,7 @@ extension OnboardingView { .frame( minWidth: pageWidth, maxWidth: pageWidth, - minHeight: Self.windowHeight, + minHeight: Self.minimumWindowHeight, maxHeight: .infinity) .background(Color(NSColor.windowBackgroundColor)) .onAppear { diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index e5491f4823ff..4aa91182cc38 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -54,6 +54,25 @@ struct OnboardingViewSmokeTests { #expect(taller - baseline == 200) } + @Test func `onboarding window fits within a short visible screen`() { + let visibleFrame = NSRect(x: 0, y: 78, width: 1600, height: 626) + let frame = OnboardingController.initialWindowFrame(visibleFrame: visibleFrame) + + #expect(frame.height == visibleFrame.height) + #expect(frame.minY == visibleFrame.minY) + #expect(frame.maxY == visibleFrame.maxY) + } + + @Test func `short onboarding window keeps a usable scrollable page`() { + let short = OnboardingView.contentHeight(for: 626, usesCompactHero: false) + let preferred = OnboardingView.contentHeight( + for: OnboardingView.windowHeight, + usesCompactHero: false) + + #expect(short == 409) + #expect(short < preferred) + } + @Test func `page order delegates setup after inference to Crestodian`() { let order = OnboardingView.pageOrder( for: .local, diff --git a/docs/install/installer.md b/docs/install/installer.md index 990f1b29b9af..4cdda74a8614 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -211,8 +211,8 @@ by default, plus git-checkout installs under the same prefix flow. If a gateway service is already loaded from that same prefix, the script runs - `openclaw gateway install --force`, then `openclaw gateway restart`, and - probes gateway health best-effort. + `openclaw gateway install --force`, which activates the replacement service, + and then probes gateway health best-effort. diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index 611af695818c..2bbdef363d1a 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -1274,12 +1274,8 @@ refresh_gateway_service_if_loaded() { return 0 fi - if ! "$claw" gateway restart >/dev/null 2>&1; then - emit_json '{"event":"step","name":"gateway-service","status":"warn","reason":"restart-failed"}' - log "Warning: gateway service restart failed; continuing. Run: openclaw gateway restart" - return 0 - fi - + # `gateway install --force` activates the replacement service. A second + # restart can kill startup migrations and strand their lock until expiry. "$claw" gateway status --probe --json >/dev/null 2>&1 || true emit_json '{"event":"step","name":"gateway-service","status":"ok"}' } diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 884af72fe690..af5ea93013d1 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -164,6 +164,39 @@ describe("install-cli.sh", () => { expect(result.stdout + result.stderr).not.toContain("unbound variable"); }); + it("does not restart a gateway again after force-install activates it", () => { + const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-gateway-refresh-")); + const prefix = join(tmp, "prefix"); + const bin = join(prefix, "bin"); + const commandLog = join(tmp, "commands.log"); + const openclaw = join(bin, "openclaw"); + mkdirSync(bin, { recursive: true }); + writeFileSync(openclaw, '#!/bin/bash\nprintf "%s\\n" "$*" >> "$COMMAND_LOG"\n'); + chmodSync(openclaw, 0o755); + + try { + const result = runInstallCliShell( + [ + "set -euo pipefail", + `cd ${JSON.stringify(process.cwd())}`, + `source ${JSON.stringify(SCRIPT_PATH)}`, + `PREFIX=${JSON.stringify(prefix)}`, + "is_gateway_daemon_loaded() { return 0; }", + "refresh_gateway_service_if_loaded", + ].join("\n"), + { COMMAND_LOG: commandLog }, + ); + + expect(result.status).toBe(0); + expect(readFileSync(commandLog, "utf8").trim().split("\n")).toEqual([ + "gateway install --force", + "gateway status --probe --json", + ]); + } finally { + rmSync(tmp, { force: true, recursive: true }); + } + }); + it("keeps HOME for default prefix while OPENCLAW_HOME controls git checkout paths", () => { const tmp = mkdtempSync(join(tmpdir(), "openclaw-install-cli-home-")); const osHome = join(tmp, "os-home");