fix(macos): defer cookie import banner until inline browser opens (#106255)

* fix(macos): defer browser import offer

* chore(macos): sync native i18n inventory

* chore: keep release note context in PR

* fix(macos): preserve first eligible browser offer

* fix(macos): retry armed browser offer on update

* fix(macos): retry browser offer after unavailable status

* fix(macos): retain browser offer reconnect retry

* fix(macos): retry stale browser import offer

* fix(macos): retry browser offer after onboarding

* chore(macos): sync native i18n inventory

* fix(macos): cancel stale browser offer
This commit is contained in:
Peter Steinberger
2026-07-13 09:28:16 -07:00
committed by GitHub
parent a13796bcfd
commit ce1c91d1ab
9 changed files with 327 additions and 53 deletions
+16 -16
View File
@@ -25587,7 +25587,7 @@
},
{
"kind": "ui-localized-call",
"line": 139,
"line": 162,
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift",
"source": "Browser import requires Local mode",
"surface": "apple",
@@ -25595,7 +25595,7 @@
},
{
"kind": "ui-localized-call",
"line": 141,
"line": 164,
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift",
"source": "Switch this Mac app to a local Gateway before importing browser cookies.",
"surface": "apple",
@@ -25603,7 +25603,7 @@
},
{
"kind": "ui-localized-call",
"line": 161,
"line": 190,
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift",
"source": "No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac.",
"surface": "apple",
@@ -25611,7 +25611,7 @@
},
{
"kind": "ui-localized-call",
"line": 163,
"line": 192,
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift",
"source": "System browser profile import is disabled in the local Gateway configuration.",
"surface": "apple",
@@ -25619,7 +25619,7 @@
},
{
"kind": "ui-localized-call",
"line": 164,
"line": 194,
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift",
"source": "No browser login available",
"surface": "apple",
@@ -25627,7 +25627,7 @@
},
{
"kind": "ui-localized-call",
"line": 174,
"line": 206,
"path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift",
"source": "Browser import unavailable",
"surface": "apple",
@@ -27147,7 +27147,7 @@
},
{
"kind": "conditional-branch",
"line": 665,
"line": 718,
"path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift",
"source": "[\\(host)]",
"surface": "apple",
@@ -29027,7 +29027,7 @@
},
{
"kind": "ui-call",
"line": 108,
"line": 103,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Settings...",
"surface": "apple",
@@ -29035,7 +29035,7 @@
},
{
"kind": "ui-call",
"line": 115,
"line": 110,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Back",
"surface": "apple",
@@ -29043,7 +29043,7 @@
},
{
"kind": "ui-call",
"line": 120,
"line": 115,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Forward",
"surface": "apple",
@@ -29051,7 +29051,7 @@
},
{
"kind": "conditional-branch",
"line": 138,
"line": 133,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw - Voice Wake live meter active",
"surface": "apple",
@@ -29059,7 +29059,7 @@
},
{
"kind": "conditional-branch",
"line": 139,
"line": 134,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "OpenClaw",
"surface": "apple",
@@ -29067,7 +29067,7 @@
},
{
"kind": "conditional-branch",
"line": 339,
"line": 334,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Close Canvas",
"surface": "apple",
@@ -29075,7 +29075,7 @@
},
{
"kind": "conditional-branch",
"line": 339,
"line": 334,
"path": "apps/macos/Sources/OpenClaw/MenuBar.swift",
"source": "Open Canvas",
"surface": "apple",
@@ -29651,7 +29651,7 @@
},
{
"kind": "conditional-branch",
"line": 687,
"line": 688,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Finish",
"surface": "apple",
@@ -29659,7 +29659,7 @@
},
{
"kind": "conditional-branch",
"line": 687,
"line": 688,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Next",
"surface": "apple",
@@ -119,26 +119,50 @@ final class BrowserProfileImportModel {
self.phaseGeneration += 1
}
/// Launch/connect/window triggers. Only fills an empty banner slot so a
/// visible offer, in-flight import, or result is never clobbered by a
/// background status poll.
/// First inline-browser open trigger. Only fills an empty banner slot so a
/// visible offer, in-flight import, or result is never clobbered by the
/// status poll.
@discardableResult
func refreshIfIdle() async -> Bool {
func refreshIfIdle(
while shouldApply: @escaping @MainActor () -> Bool = { true }) async -> Bool
{
guard !self.dismissedThisSession, case .hidden = self.phase else { return false }
await self.refresh(force: false)
return true
return await self.performRefresh(force: false, shouldApply: shouldApply).didApply
}
/// Defers the one-shot automatic offer until the app can actually query
/// the host-local import endpoint. A remote or pre-onboarding sidebar open
/// must not consume the first eligible local attempt.
@discardableResult
func requestAutomaticOfferIfEligible(
while shouldApply: @escaping @MainActor () -> Bool = { true }) async -> Bool
{
guard self.isOnboarded(), self.isLocalMode(), shouldApply() else { return false }
return await self.refreshIfIdle(while: shouldApply)
}
/// Force (Settings Import) re-offers even after a persisted dismissal
/// and reports why nothing can be offered so the caller can tell the user.
@discardableResult
func refresh(force: Bool) async -> ForceRefreshOutcome {
await self.performRefresh(force: force).outcome
}
/// Automatic offers are consumed only after a status response is applied.
/// Failed or stale startup/reconnect requests stay retryable.
private func performRefresh(
force: Bool,
shouldApply: @escaping @MainActor () -> Bool = { true }) async
-> (outcome: ForceRefreshOutcome, didApply: Bool)
{
guard self.isOnboarded(), self.isLocalMode() else {
self.setPhase(.hidden)
return .unavailable(
title: String(localized: "Browser import requires Local mode"),
message: String(
localized: "Switch this Mac app to a local Gateway before importing browser cookies."))
return (
.unavailable(
title: String(localized: "Browser import requires Local mode"),
message: String(
localized: "Switch this Mac app to a local Gateway before importing browser cookies.")),
false)
}
let generation = self.phaseGeneration
do {
@@ -150,9 +174,14 @@ final class BrowserProfileImportModel {
// must stay dismissed); a forced refresh wins over everything
// except an import that is already running.
if force {
if case .importing = self.phase { return .offering }
if case .importing = self.phase { return (.offering, true) }
} else {
guard self.phaseGeneration == generation else { return .offering }
// The sidebar may close while the host-local status request is
// in flight. Never surface its automatic offer after that UI
// owner has gone away.
guard self.phaseGeneration == generation, shouldApply() else {
return (.offering, false)
}
}
guard Self.shouldOffer(status: status, force: force) else {
self.setPhase(.hidden)
@@ -161,18 +190,22 @@ final class BrowserProfileImportModel {
localized: "No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac.")
: String(
localized: "System browser profile import is disabled in the local Gateway configuration.")
return .unavailable(title: String(localized: "No browser login available"), message: message)
return (
.unavailable(title: String(localized: "No browser login available"), message: message),
true)
}
self.setPhase(.offering(status))
return .offering
return (.offering, true)
} catch {
// Same interleaving rule: a failed poll only clears state it owns.
if force, self.phaseGeneration == generation {
if case .importing = self.phase {} else { self.setPhase(.hidden) }
}
return .unavailable(
title: String(localized: "Browser import unavailable"),
message: error.localizedDescription)
return (
.unavailable(
title: String(localized: "Browser import unavailable"),
message: error.localizedDescription),
false)
}
}
@@ -235,6 +235,10 @@ final class DashboardManager {
self.controller?.closeDashboard()
}
func handleOnboardingCompletion() {
self.controller?.handleOnboardingCompletion()
}
func navigateBack() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateBack()
@@ -74,20 +74,31 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private var auth: DashboardWindowAuth
private let updater: UpdaterProviding?
private var updateBridgeEnabled: Bool
private let requestBrowserProfileImportOffer:
@MainActor (@escaping @MainActor () -> Bool) async -> Bool
private var canGoBackObservation: NSKeyValueObservation?
private var canGoForwardObservation: NSKeyValueObservation?
private var didRequestBrowserProfileImportOffer = false
private var browserProfileImportOfferIsArmed = false
private var browserProfileImportOfferRequestIsInFlight = false
private var browserProfileImportOfferRetryPending = false
init(
url: URL,
auth: DashboardWindowAuth,
updater: UpdaterProviding? = nil,
updateBridgeEnabled: Bool = true)
updateBridgeEnabled: Bool = true,
requestBrowserProfileImportOffer:
@escaping @MainActor (@escaping @MainActor () -> Bool) async -> Bool = { shouldApply in
await BrowserProfileImportModel.shared.requestAutomaticOfferIfEligible(while: shouldApply)
})
{
let shouldEnableUpdateBridge = updater?.isAvailable == true && updateBridgeEnabled
self.currentURL = url
self.auth = auth
self.updater = updater
self.updateBridgeEnabled = shouldEnableUpdateBridge
self.requestBrowserProfileImportOffer = requestBrowserProfileImportOffer
let dataStore = WKWebsiteDataStore.default()
let config = WKWebViewConfiguration()
@@ -261,6 +272,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.setUpdateBridgeEnabled(updateBridgeEnabled)
}
self.load(url)
self.requestBrowserProfileImportOfferIfNeeded()
}
/// Miniaturized windows report `isVisible == false` but must still follow
@@ -307,10 +319,51 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.webView.load(URLRequest(url: url))
}
private func openLinkBrowser(_ url: URL) {
private func openLinkBrowser(_ url: URL, requestBrowserProfileImportOffer: Bool = true) {
self.linkBrowserItem.isCollapsed = false
self.linkBrowser.open(url)
window?.makeFirstResponder(self.linkBrowser.activeWebView)
if requestBrowserProfileImportOffer {
self.browserProfileImportOfferIsArmed = true
self.requestBrowserProfileImportOfferIfNeeded()
}
}
private func requestBrowserProfileImportOfferIfNeeded() {
guard self.browserProfileImportOfferIsArmed,
!self.linkBrowserItem.isCollapsed,
!self.didRequestBrowserProfileImportOffer
else { return }
if self.browserProfileImportOfferRequestIsInFlight {
// Gateway readiness can arrive while the status poll awaits transport.
// Latch one retry so in-flight dedupe does not discard that reconnect signal.
self.browserProfileImportOfferRetryPending = true
return
}
self.browserProfileImportOfferRequestIsInFlight = true
Task { [weak self] in
guard let self else { return }
let didApply = await self.requestBrowserProfileImportOffer { [weak self] in
guard let self else { return false }
return self.browserProfileImportOfferIsArmed &&
!self.linkBrowserItem.isCollapsed &&
!self.didRequestBrowserProfileImportOffer
}
self.browserProfileImportOfferRequestIsInFlight = false
let shouldRetry = self.browserProfileImportOfferRetryPending && !didApply
self.browserProfileImportOfferRetryPending = false
if didApply {
self.didRequestBrowserProfileImportOffer = true
} else if shouldRetry {
self.requestBrowserProfileImportOfferIfNeeded()
}
}
}
func handleOnboardingCompletion() {
// A pre-onboarding inline browser leaves the one-shot armed. Retry at
// the eligibility transition so it does not depend on later navigation.
self.requestBrowserProfileImportOfferIfNeeded()
}
private func closeLinkBrowser(focusDashboard: Bool = true) {
@@ -984,8 +1037,8 @@ extension DashboardWindowController {
self.splitViewController.splitView.autosaveName
}
func _testOpenLinkBrowser(_ url: URL) {
self.openLinkBrowser(url)
func _testOpenLinkBrowser(_ url: URL, requestBrowserProfileImportOffer: Bool = false) {
self.openLinkBrowser(url, requestBrowserProfileImportOffer: requestBrowserProfileImportOffer)
}
func _testCloseLinkBrowser() {
@@ -78,11 +78,6 @@ struct OpenClawApp: App {
}
.onChange(of: self.controlChannel.state) { _, _ in
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
if self.controlChannel.state == .connected {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
Task { await BrowserProfileImportModel.shared.refreshIfIdle() }
}
}
}
.onChange(of: self.gatewayManager.status) { _, _ in
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
@@ -454,9 +449,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch")
}
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) {
Task { await BrowserProfileImportModel.shared.refreshIfIdle() }
}
#if DEBUG
// Screenshot/demo helper: show the pairing panel with sample requests.
@@ -496,6 +496,7 @@ final class OnboardingController: NSObject, NSWindowDelegate {
UserDefaults.standard.set(true, forKey: onboardingSeenKey)
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
AppStateStore.shared.onboardingSeen = true
DashboardManager.shared.handleOnboardingCompletion()
}
func show() {
@@ -29,7 +29,10 @@ private final class BrowserImportTransportStub {
}
"""
func makeModel(isLocalMode: @escaping @MainActor () -> Bool = { true }) -> BrowserProfileImportModel {
func makeModel(
isOnboarded: @escaping @MainActor () -> Bool = { true },
isLocalMode: @escaping @MainActor () -> Bool = { true }) -> BrowserProfileImportModel
{
BrowserProfileImportModel(
transport: { [weak self] request in
guard let self else { throw StubError() }
@@ -50,7 +53,7 @@ private final class BrowserImportTransportStub {
return Data(#"{"ok":true}"#.utf8)
}
},
isOnboarded: { true },
isOnboarded: isOnboarded,
isLocalMode: isLocalMode)
}
@@ -125,6 +128,55 @@ struct BrowserProfileImportModelTests {
#expect(stub.requests.isEmpty)
}
@Test func `automatic offer request waits for onboarding and local mode`() async {
let stub = BrowserImportTransportStub()
var isOnboarded = false
var isLocalMode = false
let model = stub.makeModel(
isOnboarded: { isOnboarded },
isLocalMode: { isLocalMode })
#expect(await !model.requestAutomaticOfferIfEligible())
isOnboarded = true
#expect(await !model.requestAutomaticOfferIfEligible())
isLocalMode = true
#expect(await model.requestAutomaticOfferIfEligible())
#expect(stub.requests(for: "/system-profile-import/status").count == 1)
}
@Test func `failed automatic status request stays retryable`() async {
let stub = BrowserImportTransportStub()
stub.failingPaths = ["/system-profile-import/status"]
let model = stub.makeModel()
#expect(await !model.requestAutomaticOfferIfEligible())
stub.failingPaths = []
#expect(await model.requestAutomaticOfferIfEligible())
#expect(stub.requests(for: "/system-profile-import/status").count == 2)
}
@Test func `automatic offer does not apply after its inline browser closes`() async {
let stub = BrowserImportTransportStub()
let model = stub.makeModel()
let gate = ContinuationBox()
var shouldApply = true
stub.beforeStatusResponse = {
await withCheckedContinuation { gate.continuation = $0 }
}
let request = Task {
await model.requestAutomaticOfferIfEligible(while: { shouldApply })
}
while gate.continuation == nil {
await Task.yield()
}
shouldApply = false
gate.continuation?.resume()
#expect(await !request.value)
#expect(model.phase == .hidden)
}
@Test func `import success records counts and target`() async throws {
let stub = BrowserImportTransportStub()
let model = stub.makeModel()
@@ -212,7 +264,7 @@ struct BrowserProfileImportModelTests {
model._testSetPhase(.offering(forced))
gate.continuation?.resume()
_ = await idle.value
#expect(await !idle.value)
#expect(model.phase == .offering(forced))
}
@@ -25,7 +25,20 @@ private actor DashboardRouteAuthGate {
self.token = token
}
func probes() -> Int { self.probeCount }
func probes() -> Int {
self.probeCount
}
}
@MainActor
private final class DashboardBrowserImportGate {
var isOnboarded = false
private(set) var requestCount = 0
func request() -> Bool {
self.requestCount += 1
return self.isOnboarded
}
}
@Suite(.serialized)
@@ -83,12 +96,138 @@ struct DashboardWindowSmokeTests {
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
#expect(controller._testNavigationWebViewIdentity == controller._testDashboardWebViewIdentity)
controller._testOpenLinkBrowser(try #require(URL(string: "https://docs.openclaw.ai/")))
try controller._testOpenLinkBrowser(#require(URL(string: "https://docs.openclaw.ai/")))
let linkWebView = try #require(controller._testLinkBrowserWebViewIdentity)
#expect(controller._testFocusLinkBrowser())
#expect(controller._testNavigationWebViewIdentity == linkWebView)
}
@Test func `browser import offer retries until the first completed inline browser request`() async throws {
let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/"))
var requestCount = 0
var firstRequestContinuation: CheckedContinuation<Bool, Never>?
let controller = DashboardWindowController(
url: dashboard,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil),
requestBrowserProfileImportOffer: { _ in
requestCount += 1
if requestCount == 1 {
return await withCheckedContinuation { continuation in
firstRequestContinuation = continuation
}
}
return true
})
defer { controller.closeDashboard() }
controller.show()
#expect(requestCount == 0)
let link = try #require(URL(string: "https://docs.openclaw.ai/"))
controller._testOpenLinkBrowser(link)
controller.update(
url: dashboard,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
#expect(requestCount == 0)
controller._testOpenLinkBrowser(link, requestBrowserProfileImportOffer: true)
for _ in 0..<200 where firstRequestContinuation == nil {
await Task.yield()
}
#expect(requestCount == 1)
controller.update(
url: dashboard,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil))
firstRequestContinuation?.resume(returning: false)
firstRequestContinuation = nil
for _ in 0..<200 where requestCount == 1 {
await Task.yield()
}
#expect(requestCount == 2)
controller._testCloseLinkBrowser()
controller._testOpenLinkBrowser(link, requestBrowserProfileImportOffer: true)
for _ in 0..<10 {
await Task.yield()
}
#expect(requestCount == 2)
}
@Test func `browser import offer retries when onboarding completes with browser open`() async throws {
let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/"))
let gate = DashboardBrowserImportGate()
let controller = DashboardWindowController(
url: dashboard,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil),
requestBrowserProfileImportOffer: { _ in gate.request() })
defer { controller.closeDashboard() }
let manager = DashboardManager._testMake()
manager._testSetController(controller)
let link = try #require(URL(string: "https://docs.openclaw.ai/"))
controller._testOpenLinkBrowser(link, requestBrowserProfileImportOffer: true)
for _ in 0..<200 where gate.requestCount == 0 {
await Task.yield()
}
#expect(gate.requestCount == 1)
gate.isOnboarded = true
manager.handleOnboardingCompletion()
for _ in 0..<200 where gate.requestCount == 1 {
await Task.yield()
}
#expect(gate.requestCount == 2)
manager.handleOnboardingCompletion()
for _ in 0..<10 {
await Task.yield()
}
#expect(gate.requestCount == 2)
}
@Test func `closing inline browser invalidates an in-flight import offer`() async throws {
let dashboard = try #require(URL(string: "http://127.0.0.1:18789/control/"))
var requestCount = 0
var firstRequestContinuation: CheckedContinuation<Void, Never>?
var firstRequestApplied: Bool?
let controller = DashboardWindowController(
url: dashboard,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil),
requestBrowserProfileImportOffer: { shouldApply in
requestCount += 1
if requestCount == 1 {
await withCheckedContinuation { continuation in
firstRequestContinuation = continuation
}
firstRequestApplied = shouldApply()
return firstRequestApplied == true
}
return shouldApply()
})
defer { controller.closeDashboard() }
let link = try #require(URL(string: "https://docs.openclaw.ai/"))
controller._testOpenLinkBrowser(link, requestBrowserProfileImportOffer: true)
for _ in 0..<200 where firstRequestContinuation == nil {
await Task.yield()
}
#expect(requestCount == 1)
controller._testCloseLinkBrowser()
firstRequestContinuation?.resume()
for _ in 0..<200 where firstRequestApplied == nil {
await Task.yield()
}
#expect(firstRequestApplied == false)
controller._testOpenLinkBrowser(link, requestBrowserProfileImportOffer: true)
for _ in 0..<200 where requestCount == 1 {
await Task.yield()
}
#expect(requestCount == 2)
}
@Test func `dashboard parses only bounded native link requests`() throws {
let request = DashboardWindowController.linkRequest(from: [
"type": "open-link",
@@ -651,9 +790,9 @@ struct DashboardWindowSmokeTests {
manager._testSetController(controller)
defer { manager._testController()?.closeDashboard() }
await manager.handleEndpointState(.ready(
try await manager.handleEndpointState(.ready(
mode: .remote,
url: try #require(URL(string: "ws://127.0.0.1:60001")),
url: #require(URL(string: "ws://127.0.0.1:60001")),
token: nil,
password: nil,
routeRevision: 2))
+1 -1
View File
@@ -67,7 +67,7 @@ Right-click an external link to choose **Open in Sidebar**, **Open in Default Br
## Import browser logins
When the app runs against a local Gateway and a Chrome-family profile with cookies exists on the Mac, the dashboard window shows a dismissible banner offering to copy those cookies into an isolated managed profile that agents use for browsing. Choose a profile from the banner's **Import** control (Touch ID may be required); progress and the imported-cookie count appear inline, and only cookies are copied — passwords never leave the source browser. Dismissing the banner records the choice; **Settings → General → Browser login → Import…** re-offers it at any time. See [Browser](/cli/browser) for the underlying import flow and the `browser.allowSystemProfileImport` gate.
The first time the browser sidebar opens while the app runs against a local Gateway, the dashboard shows a dismissible banner when a Chrome-family profile with cookies exists on the Mac. The banner offers to copy those cookies into an isolated managed profile that agents use for browsing. Choose a profile from its **Import** control (Touch ID may be required); progress and the imported-cookie count appear inline, and only cookies are copied — passwords never leave the source browser. Dismissing the banner records the choice; **Settings → General → Browser login → Import…** re-offers it at any time. See [Browser](/cli/browser) for the underlying import flow and the `browser.allowSystemProfileImport` gate.
## Choose a Gateway mode