From 5f2f9a05a3732af7aeea845d44db10924b656494 Mon Sep 17 00:00:00 2001 From: ImLukeF <92253590+ImLukeF@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:30:13 +1100 Subject: [PATCH] macOS/onboarding: add one-time security acknowledgement --- apps/macos/Sources/OpenClaw/Constants.swift | 1 + apps/macos/Sources/OpenClaw/Onboarding.swift | 20 +++++++++ .../OpenClaw/OnboardingView+Actions.swift | 8 +++- .../OpenClaw/OnboardingView+Pages.swift | 23 ++++++++++ .../OpenClaw/OnboardingView+Testing.swift | 1 + .../OnboardingViewSmokeTests.swift | 44 +++++++++++++++++++ 6 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 7065702d688..d890d787455 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -6,6 +6,7 @@ let launchdLabel = "ai.openclaw.mac" let gatewayLaunchdLabel = "ai.openclaw.gateway" let onboardingVersionKey = "openclaw.onboardingVersion" let onboardingSeenKey = "openclaw.onboardingSeen" +let onboardingSecurityAcknowledgedKey = "openclaw.onboardingSecurityAcknowledged" let currentOnboardingVersion = 7 let pauseDefaultsKey = "openclaw.pauseEnabled" let iconAnimationsEnabledKey = "openclaw.iconAnimationsEnabled" diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index e9cdd8ee240..cac913a6208 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -25,6 +25,7 @@ final class OnboardingController { if ProcessInfo.processInfo.isNixMode { // Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply. UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") + UserDefaults.standard.set(true, forKey: onboardingSecurityAcknowledgedKey) UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) AppStateStore.shared.onboardingSeen = true return @@ -90,6 +91,7 @@ struct OnboardingView: View { @State var onboardingWizard = OnboardingWizardModel() @State var didLoadOnboardingSkills = false @State var localGatewayProbe: LocalGatewayProbe? + @State var securityNoticeAcknowledged: Bool @Bindable var state: AppState var permissionMonitor: PermissionMonitor @@ -148,13 +150,27 @@ struct OnboardingView: View { self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete } + var isSecurityNoticeBlocking: Bool { + self.activePageIndex == 0 && !self.securityNoticeAcknowledged + } + var canAdvance: Bool { + if self.isSecurityNoticeBlocking { + return false + } if self.activePageIndex == self.cliPageIndex { return self.cliInstalled && !self.installingCLI } return !self.isWizardBlocking } + static func resolveSecurityNoticeAcknowledged( + onboardingSeen: Bool, + storedAcknowledgement: Bool) -> Bool + { + storedAcknowledgement || onboardingSeen + } + var devLinkCommand: String { let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" return "npm install -g openclaw@\(version)" @@ -177,6 +193,10 @@ struct OnboardingView: View { self.state = state self.permissionMonitor = permissionMonitor self._gatewayDiscovery = State(initialValue: discoveryModel) + self._securityNoticeAcknowledged = State( + initialValue: Self.resolveSecurityNoticeAcknowledged( + onboardingSeen: state.onboardingSeen, + storedAcknowledgement: UserDefaults.standard.bool(forKey: onboardingSecurityAcknowledgedKey))) self._onboardingChatModel = State( initialValue: OpenClawChatViewModel( sessionKey: "onboarding", diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index 23b051cbc99..4c186419cff 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -45,7 +45,7 @@ extension OnboardingView { } func handleNext() { - if self.isWizardBlocking { return } + if !self.canAdvance { return } if self.currentPage < self.pageCount - 1 { withAnimation { self.currentPage += 1 } } else { @@ -53,7 +53,13 @@ extension OnboardingView { } } + func setSecurityNoticeAcknowledged(_ acknowledged: Bool) { + self.securityNoticeAcknowledged = acknowledged + UserDefaults.standard.set(acknowledged, forKey: onboardingSecurityAcknowledgedKey) + } + func finish() { + UserDefaults.standard.set(true, forKey: onboardingSecurityAcknowledgedKey) UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen") UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) OnboardingController.shared.close() diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 186f99abf1c..438429a9e06 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -72,6 +72,29 @@ extension OnboardingView { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color.orange.opacity(0.06))) .frame(maxWidth: 520) + + self.onboardingCard(spacing: 8, padding: 14) { + if self.securityNoticeAcknowledged { + Label("Security notice acknowledged on this Mac.", systemImage: "checkmark.shield.fill") + .font(.callout.weight(.medium)) + .foregroundStyle(Color(nsColor: .systemGreen)) + } else { + Toggle( + isOn: Binding( + get: { self.securityNoticeAcknowledged }, + set: { self.setSecurityNoticeAcknowledged($0) })) + { + Text("I understand the risks and want to continue.") + .font(.callout.weight(.medium)) + } + .toggleStyle(.checkbox) + + Text("You only need to acknowledge this once on this Mac.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: 520) } .padding(.top, 16) } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift index 2bd9c525ad4..4f62cd8b998 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -59,6 +59,7 @@ extension OnboardingView { _ = view.connectionPage() view.currentPage = 0 + view.setSecurityNoticeAcknowledged(true) view.handleNext() view.handleBack() diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 5b816d3cd5a..676ae03589d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -27,6 +27,50 @@ struct OnboardingViewSmokeTests { #expect(!order.contains(8)) } + @Test func `fresh installs require security acknowledgement before advancing`() { + let defaults = UserDefaults.standard + let previous = defaults.object(forKey: onboardingSecurityAcknowledgedKey) + defaults.removeObject(forKey: onboardingSecurityAcknowledgedKey) + defer { + if let previous { + defaults.set(previous, forKey: onboardingSecurityAcknowledgedKey) + } else { + defaults.removeObject(forKey: onboardingSecurityAcknowledgedKey) + } + } + + let freshState = AppState(preview: true) + freshState.onboardingSeen = false + let freshView = OnboardingView( + state: freshState, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) + + #expect(freshView.isSecurityNoticeBlocking) + #expect(!freshView.canAdvance) + + defaults.set(true, forKey: onboardingSecurityAcknowledgedKey) + + let acknowledgedState = AppState(preview: true) + acknowledgedState.onboardingSeen = false + let acknowledgedView = OnboardingView( + state: acknowledgedState, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)) + + #expect(!acknowledgedView.isSecurityNoticeBlocking) + #expect(acknowledgedView.canAdvance) + } + + @Test func `existing onboarded users keep their acknowledgement`() { + #expect(OnboardingView.resolveSecurityNoticeAcknowledged( + onboardingSeen: true, + storedAcknowledgement: false)) + #expect(!OnboardingView.resolveSecurityNoticeAcknowledged( + onboardingSeen: false, + storedAcknowledgement: false)) + } + @Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async { let override = FileManager().temporaryDirectory .appendingPathComponent("openclaw-config-\(UUID().uuidString)")