mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
a13796bcfd
commit
ce1c91d1ab
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user