From 9ad59ae7880254a77f896fb5b092d05bf2b1700a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Jul 2026 11:11:42 -0700 Subject: [PATCH] improve(macos): replace browser cookie-import modal with an inline dashboard banner (#104591) * feat(macos): replace browser cookie-import modal with dashboard banner * fix(macos): cross-browser menu identity, banner pane ownership, docs map * chore: refresh native i18n inventory * fix(macos): stale import status polls no longer clobber fresher banner state * fix(macos): generation-guard stale import polls; drop dashboard-show poll trigger * fix(macos): session-sticky dismissal and localized banner copy --- apps/.i18n/native-source.json | 316 ++++++++++------- .../BrowserProfileImportBannerView.swift | 246 +++++++++++++ .../OpenClaw/BrowserProfileImportModel.swift | 265 ++++++++++++++ .../BrowserProfileImportPrompter.swift | 179 ---------- .../OpenClaw/DashboardWindowController.swift | 2 +- .../Sources/OpenClaw/GeneralSettings.swift | 15 +- apps/macos/Sources/OpenClaw/MenuBar.swift | 5 +- .../BrowserProfileImportModelTests.swift | 322 ++++++++++++++++++ .../BrowserProfileImportPrompterTests.swift | 58 ---- docs/docs_map.md | 1 + docs/platforms/macos.md | 4 + 11 files changed, 1054 insertions(+), 359 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift create mode 100644 apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift delete mode 100644 apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift create mode 100644 apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportModelTests.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportPrompterTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 489d9ab8f477..dfdf6e28d029 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -15122,68 +15122,148 @@ "id": "native.apple.69b64bb81b900cc4" }, { - "kind": "ui-named-argument", - "line": 65, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "Browser import requires Local mode", + "kind": "ui-localized-call", + "line": 33, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Use your browser logins", "surface": "apple", - "id": "native.apple.f21ae54762766350" + "id": "native.apple.d1faee12838b677f" }, { - "kind": "ui-named-argument", - "line": 66, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "Switch this Mac app to a local Gateway before importing browser cookies.", + "kind": "ui-localized-call-multiline", + "line": 34, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Copy cookies from \\(browsers) into an isolated agent profile. Passwords are never touched.", "surface": "apple", - "id": "native.apple.9c5afae0c9a2c3dc" + "id": "native.apple.c5ff31546d6a679d" }, { - "kind": "conditional-branch", - "line": 84, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac.", + "kind": "ui-localized-call", + "line": 42, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Importing browser cookies…", "surface": "apple", - "id": "native.apple.1d0cf21afa658361" + "id": "native.apple.8dc63b7646cb0bdb" }, { - "kind": "conditional-branch", - "line": 84, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "System browser profile import is disabled in the local Gateway configuration.", + "kind": "ui-localized-call-multiline", + "line": 43, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Copying \\(profile.displayName) into “\\(target)”. Touch ID may be required.", "surface": "apple", - "id": "native.apple.bf12914ddc56e2c0" + "id": "native.apple.726d99301dedf301" }, { - "kind": "ui-named-argument", - "line": 86, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "No browser login available", + "kind": "ui-localized-call", + "line": 50, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Browser logins imported", "surface": "apple", - "id": "native.apple.592461b1fb69f91f" + "id": "native.apple.afe729bf54ad5a20" }, { - "kind": "ui-named-argument", - "line": 93, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "Browser import unavailable", + "kind": "ui-localized-call-multiline", + "line": 51, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "\\(result.cookies.imported) of \\(result.cookies.total) cookies copied into “\\(result.into)” — now the default profile for agent browsing.", "surface": "apple", - "id": "native.apple.3af5e5fbc2449e11" + "id": "native.apple.f2537efb54feeba4" }, { - "kind": "ui-named-argument", - "line": 145, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", - "source": "Browser login imported", - "surface": "apple", - "id": "native.apple.d3dddd5b7efc8543" - }, - { - "kind": "ui-named-argument", - "line": 148, - "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift", + "kind": "ui-localized-call", + "line": 59, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", "source": "Browser import failed", "surface": "apple", - "id": "native.apple.11c315582dc3388a" + "id": "native.apple.aaa27f92ea6143c3" + }, + { + "kind": "ui-localized-call", + "line": 76, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "your browser", + "surface": "apple", + "id": "native.apple.0a0426e216fde2a3" + }, + { + "kind": "ui-modifier", + "line": 122, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "\\(content.title). \\(content.subtitle)", + "surface": "apple", + "id": "native.apple.b75ef7dd2b515a59" + }, + { + "kind": "ui-call", + "line": 136, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Import", + "surface": "apple", + "id": "native.apple.cc06661a63fc0cad" + }, + { + "kind": "ui-call", + "line": 149, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Retry", + "surface": "apple", + "id": "native.apple.a99a3c13a2a98ba3" + }, + { + "kind": "ui-modifier", + "line": 176, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift", + "source": "Dismiss", + "surface": "apple", + "id": "native.apple.792cfb2474ad0533" + }, + { + "kind": "ui-localized-call", + "line": 139, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift", + "source": "Browser import requires Local mode", + "surface": "apple", + "id": "native.apple.ed83068f5cbb2b04" + }, + { + "kind": "ui-localized-call", + "line": 140, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift", + "source": "Switch this Mac app to a local Gateway before importing browser cookies.", + "surface": "apple", + "id": "native.apple.eb2383ae167c90fd" + }, + { + "kind": "ui-localized-call", + "line": 160, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift", + "source": "No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac.", + "surface": "apple", + "id": "native.apple.fdf0ffe662bd620f" + }, + { + "kind": "ui-localized-call", + "line": 162, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift", + "source": "System browser profile import is disabled in the local Gateway configuration.", + "surface": "apple", + "id": "native.apple.eedf0eec72f6d287" + }, + { + "kind": "ui-localized-call", + "line": 164, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift", + "source": "No browser login available", + "surface": "apple", + "id": "native.apple.a8164afad8e43408" + }, + { + "kind": "ui-localized-call", + "line": 174, + "path": "apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift", + "source": "Browser import unavailable", + "surface": "apple", + "id": "native.apple.52bd5967f06d4ea2" }, { "kind": "conditional-branch", @@ -17499,7 +17579,7 @@ }, { "kind": "ui-call", - "line": 135, + "line": 148, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Developer", "surface": "apple", @@ -17507,7 +17587,7 @@ }, { "kind": "ui-named-argument", - "line": 137, + "line": 150, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Enable debug tools", "surface": "apple", @@ -17515,7 +17595,7 @@ }, { "kind": "ui-named-argument", - "line": 138, + "line": 151, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Show the Debug page with development utilities.", "surface": "apple", @@ -17523,7 +17603,7 @@ }, { "kind": "ui-call", - "line": 145, + "line": 158, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "App session", "surface": "apple", @@ -17531,7 +17611,7 @@ }, { "kind": "ui-call", - "line": 147, + "line": 160, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Quit only when you want to stop the menu bar app completely.", "surface": "apple", @@ -17539,7 +17619,7 @@ }, { "kind": "ui-call", - "line": 152, + "line": 165, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Quit", "surface": "apple", @@ -17547,7 +17627,7 @@ }, { "kind": "conditional-branch", - "line": 172, + "line": 185, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw paused", "surface": "apple", @@ -17555,7 +17635,7 @@ }, { "kind": "ui-call", - "line": 182, + "line": 195, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw active", "surface": "apple", @@ -17563,7 +17643,7 @@ }, { "kind": "conditional-branch", - "line": 200, + "line": 213, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Processing messages through the local Gateway on this Mac.", "surface": "apple", @@ -17571,7 +17651,7 @@ }, { "kind": "conditional-branch", - "line": 202, + "line": 215, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Connected to a remote Gateway configuration.", "surface": "apple", @@ -17579,7 +17659,7 @@ }, { "kind": "conditional-branch", - "line": 204, + "line": 217, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Ready to run after you choose a Gateway connection.", "surface": "apple", @@ -17587,7 +17667,7 @@ }, { "kind": "ui-named-argument", - "line": 212, + "line": 225, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Connection", "surface": "apple", @@ -17595,7 +17675,7 @@ }, { "kind": "ui-named-argument", - "line": 213, + "line": 226, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Choose where the Gateway runs and how this Mac app reaches it.", "surface": "apple", @@ -17603,7 +17683,7 @@ }, { "kind": "ui-call", - "line": 270, + "line": 283, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "\\(Int(ping)) ms", "surface": "apple", @@ -17611,7 +17691,7 @@ }, { "kind": "conditional-branch", - "line": 304, + "line": 317, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Local Gateway", "surface": "apple", @@ -17619,7 +17699,7 @@ }, { "kind": "conditional-branch", - "line": 305, + "line": 318, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote Gateway direct", "surface": "apple", @@ -17627,7 +17707,7 @@ }, { "kind": "conditional-branch", - "line": 305, + "line": 318, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote Gateway via SSH", "surface": "apple", @@ -17635,7 +17715,7 @@ }, { "kind": "conditional-branch", - "line": 306, + "line": 319, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway not configured", "surface": "apple", @@ -17643,7 +17723,7 @@ }, { "kind": "conditional-branch", - "line": 312, + "line": 325, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw starts and monitors the Gateway on this Mac.", "surface": "apple", @@ -17651,7 +17731,7 @@ }, { "kind": "conditional-branch", - "line": 321, + "line": 334, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Choose local or remote before the app can attach to a Gateway.", "surface": "apple", @@ -17659,7 +17739,7 @@ }, { "kind": "ui-call", - "line": 327, + "line": 340, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway", "surface": "apple", @@ -17667,7 +17747,7 @@ }, { "kind": "ui-named-argument", - "line": 329, + "line": 342, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "OpenClaw runs", "surface": "apple", @@ -17675,7 +17755,7 @@ }, { "kind": "ui-named-argument", - "line": 330, + "line": 343, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Pick whether this app owns a local Gateway or attaches to another host.", "surface": "apple", @@ -17683,7 +17763,7 @@ }, { "kind": "ui-call", - "line": 333, + "line": 346, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway location", "surface": "apple", @@ -17691,7 +17771,7 @@ }, { "kind": "ui-call", - "line": 334, + "line": 347, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Not configured", "surface": "apple", @@ -17699,7 +17779,7 @@ }, { "kind": "ui-call", - "line": 335, + "line": 348, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Local (this Mac)", "surface": "apple", @@ -17707,7 +17787,7 @@ }, { "kind": "ui-call", - "line": 336, + "line": 349, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote (another host)", "surface": "apple", @@ -17715,7 +17795,7 @@ }, { "kind": "ui-named-argument", - "line": 345, + "line": 358, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Setup needed", "surface": "apple", @@ -17723,7 +17803,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 346, + "line": 359, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Local is best for this Mac. Remote is best when the Gateway already runs on a Mac Studio or server.", "surface": "apple", @@ -17731,7 +17811,7 @@ }, { "kind": "ui-call", - "line": 377, + "line": 390, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote Access", "surface": "apple", @@ -17739,7 +17819,7 @@ }, { "kind": "ui-call", - "line": 388, + "line": 401, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Discovery & Status", "surface": "apple", @@ -17747,7 +17827,7 @@ }, { "kind": "ui-call", - "line": 404, + "line": 417, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Nearby gateways", "surface": "apple", @@ -17755,7 +17835,7 @@ }, { "kind": "ui-named-argument", - "line": 427, + "line": 440, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Remote test", "surface": "apple", @@ -17763,7 +17843,7 @@ }, { "kind": "ui-named-argument", - "line": 434, + "line": 447, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Control channel", "surface": "apple", @@ -17771,7 +17851,7 @@ }, { "kind": "ui-named-argument", - "line": 461, + "line": 474, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Recommended setup", "surface": "apple", @@ -17779,7 +17859,7 @@ }, { "kind": "conditional-branch", - "line": 463, + "line": 476, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Use Tailscale Serve so the gateway has a valid HTTPS certificate.", "surface": "apple", @@ -17787,7 +17867,7 @@ }, { "kind": "conditional-branch", - "line": 463, + "line": 476, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Use Tailscale plus an SSH tunnel for stable private access.", "surface": "apple", @@ -17795,7 +17875,7 @@ }, { "kind": "ui-call", - "line": 473, + "line": 486, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Advanced", "surface": "apple", @@ -17803,7 +17883,7 @@ }, { "kind": "ui-call", - "line": 476, + "line": 489, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Identity file", "surface": "apple", @@ -17811,7 +17891,7 @@ }, { "kind": "ui-named-argument", - "line": 478, + "line": 491, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "/Users/you/.ssh/id_ed25519", "surface": "apple", @@ -17819,7 +17899,7 @@ }, { "kind": "ui-call", - "line": 480, + "line": 493, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Project root", "surface": "apple", @@ -17827,7 +17907,7 @@ }, { "kind": "ui-named-argument", - "line": 482, + "line": 495, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "/home/you/Projects/openclaw", "surface": "apple", @@ -17835,7 +17915,7 @@ }, { "kind": "ui-call", - "line": 484, + "line": 497, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "CLI path", "surface": "apple", @@ -17843,7 +17923,7 @@ }, { "kind": "ui-named-argument", - "line": 486, + "line": 499, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "/Applications/OpenClaw.app/.../openclaw", "surface": "apple", @@ -17851,7 +17931,7 @@ }, { "kind": "ui-call", - "line": 491, + "line": 504, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH command details", "surface": "apple", @@ -17859,7 +17939,7 @@ }, { "kind": "ui-named-argument", - "line": 511, + "line": 524, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Transport", "surface": "apple", @@ -17867,7 +17947,7 @@ }, { "kind": "ui-named-argument", - "line": 512, + "line": 525, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH keeps the Gateway private; direct is best for HTTPS or Tailscale Serve.", "surface": "apple", @@ -17875,7 +17955,7 @@ }, { "kind": "ui-call", - "line": 515, + "line": 528, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH tunnel", "surface": "apple", @@ -17883,7 +17963,7 @@ }, { "kind": "ui-call", - "line": 516, + "line": 529, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Direct (ws/wss)", "surface": "apple", @@ -17891,7 +17971,7 @@ }, { "kind": "ui-named-argument", - "line": 529, + "line": 542, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "SSH target", "surface": "apple", @@ -17899,7 +17979,7 @@ }, { "kind": "ui-named-argument", - "line": 529, + "line": 542, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "User and host for the remote Gateway machine.", "surface": "apple", @@ -17907,7 +17987,7 @@ }, { "kind": "ui-named-argument", - "line": 546, + "line": 559, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway URL", "surface": "apple", @@ -17915,7 +17995,7 @@ }, { "kind": "ui-named-argument", - "line": 546, + "line": 559, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "The WebSocket URL exposed by the remote Gateway.", "surface": "apple", @@ -17923,7 +18003,7 @@ }, { "kind": "ui-call", - "line": 549, + "line": 562, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "wss://gateway.example.ts.net", "surface": "apple", @@ -17931,7 +18011,7 @@ }, { "kind": "ui-call", - "line": 555, + "line": 568, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Use wss:// for public hosts. ws:// is allowed for localhost, LAN, .local, and Tailnet hosts.", "surface": "apple", @@ -17939,7 +18019,7 @@ }, { "kind": "ui-named-argument", - "line": 566, + "line": 579, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway token", "surface": "apple", @@ -17947,7 +18027,7 @@ }, { "kind": "ui-named-argument", - "line": 567, + "line": 580, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Used when the remote gateway requires token auth.", "surface": "apple", @@ -17955,7 +18035,7 @@ }, { "kind": "ui-call", - "line": 570, + "line": 583, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "remote gateway auth token (gateway.remote.token)", "surface": "apple", @@ -17963,7 +18043,7 @@ }, { "kind": "ui-call-concatenated", - "line": 575, + "line": 588, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.", "surface": "apple", @@ -17971,7 +18051,7 @@ }, { "kind": "ui-call", - "line": 594, + "line": 607, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Test remote", "surface": "apple", @@ -17979,7 +18059,7 @@ }, { "kind": "ui-call", - "line": 617, + "line": 630, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Testing…", "surface": "apple", @@ -17987,7 +18067,7 @@ }, { "kind": "ui-call", - "line": 655, + "line": 668, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Installed: \\(gatewayVersion) · Required: \\(required)", "surface": "apple", @@ -17995,7 +18075,7 @@ }, { "kind": "ui-call", - "line": 659, + "line": 672, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway \\(gatewayVersion) detected", "surface": "apple", @@ -18003,7 +18083,7 @@ }, { "kind": "ui-call", - "line": 665, + "line": 678, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Node \\(node)", "surface": "apple", @@ -18011,7 +18091,7 @@ }, { "kind": "ui-call", - "line": 677, + "line": 690, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Last failure: \\(failure)", "surface": "apple", @@ -18019,7 +18099,7 @@ }, { "kind": "ui-call", - "line": 682, + "line": 695, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Recheck", "surface": "apple", @@ -18027,7 +18107,7 @@ }, { "kind": "ui-call", - "line": 685, + "line": 698, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Gateway auto-starts in local mode via launchd (\\(gatewayLaunchdLabel)).", "surface": "apple", @@ -18035,7 +18115,7 @@ }, { "kind": "ui-call", - "line": 740, + "line": 753, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Retry now", "surface": "apple", @@ -18043,7 +18123,7 @@ }, { "kind": "ui-call", - "line": 745, + "line": 758, "path": "apps/macos/Sources/OpenClaw/GeneralSettings.swift", "source": "Open logs", "surface": "apple", @@ -18315,7 +18395,7 @@ }, { "kind": "ui-call", - "line": 107, + "line": 108, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Settings...", "surface": "apple", @@ -18323,7 +18403,7 @@ }, { "kind": "ui-call", - "line": 114, + "line": 115, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Back", "surface": "apple", @@ -18331,7 +18411,7 @@ }, { "kind": "ui-call", - "line": 119, + "line": 120, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Forward", "surface": "apple", @@ -18339,7 +18419,7 @@ }, { "kind": "conditional-branch", - "line": 137, + "line": 138, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw", "surface": "apple", @@ -18347,7 +18427,7 @@ }, { "kind": "conditional-branch", - "line": 137, + "line": 138, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw - Voice Wake live meter active", "surface": "apple", @@ -18355,7 +18435,7 @@ }, { "kind": "conditional-branch", - "line": 338, + "line": 339, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Close Canvas", "surface": "apple", @@ -18363,7 +18443,7 @@ }, { "kind": "conditional-branch", - "line": 338, + "line": 339, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Open Canvas", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift b/apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift new file mode 100644 index 000000000000..480d9df3a085 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift @@ -0,0 +1,246 @@ +import AppKit +import SwiftUI + +/// Presentation for one banner phase, derived separately from the view so the +/// copy and available actions stay unit-testable. +struct BrowserProfileImportBannerContent: Equatable { + enum Badge: Equatable { + case globe + case progress + case success + case failure + } + + enum Action: Equatable { + case importProfiles([BrowserSystemProfile]) + case retry + case none + } + + let title: String + let subtitle: String + let badge: Badge + let action: Action + + static func content(for phase: BrowserProfileImportModel.Phase) -> BrowserProfileImportBannerContent? { + switch phase { + case .hidden: + return nil + case let .offering(status): + let profiles = status.importableProfiles + let browsers = Self.browserList(for: profiles) + return BrowserProfileImportBannerContent( + title: String(localized: "Use your browser logins"), + subtitle: String(localized: """ + Copy cookies from \(browsers) into an isolated agent profile. \ + Passwords are never touched. + """), + badge: .globe, + action: .importProfiles(profiles)) + case let .importing(profile, target): + return BrowserProfileImportBannerContent( + title: String(localized: "Importing browser cookies…"), + subtitle: String(localized: """ + Copying \(profile.displayName) into “\(target)”. Touch ID may be required. + """), + badge: .progress, + action: .none) + case let .imported(result): + return BrowserProfileImportBannerContent( + title: String(localized: "Browser logins imported"), + subtitle: String(localized: """ + \(result.cookies.imported) of \(result.cookies.total) cookies copied into \ + “\(result.into)” — now the default profile for agent browsing. + """), + badge: .success, + action: .none) + case let .failed(message, _): + return BrowserProfileImportBannerContent( + title: String(localized: "Browser import failed"), + subtitle: message, + badge: .failure, + action: .retry) + } + } + + static func browserList(for profiles: [BrowserSystemProfile]) -> String { + var names: [String] = [] + for profile in profiles { + let name = profile.browserDisplayName + if !names.contains(name) { + names.append(name) + } + } + switch names.count { + case 0: + return String(localized: "your browser") + case 1: + return names[0] + default: + return ListFormatter.localizedString(byJoining: names) + } + } +} + +/// Floating, dismissible import offer shown over the dashboard — the inline +/// replacement for the old modal browser-login alert. +struct BrowserProfileImportBannerView: View { + let model: BrowserProfileImportModel + + var body: some View { + if let content = BrowserProfileImportBannerContent.content(for: self.model.phase) { + self.card(content) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + + private func card(_ content: BrowserProfileImportBannerContent) -> some View { + HStack(alignment: .center, spacing: 12) { + BannerBadgeIcon(badge: content.badge) + VStack(alignment: .leading, spacing: 2) { + Text(content.title) + .font(.system(size: 13, weight: .semibold)) + Text(content.subtitle) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 12) + self.trailingControls(for: content) + self.dismissButton(for: content) + } + .padding(.vertical, 10) + .padding(.leading, 12) + .padding(.trailing, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 1)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 6) + .accessibilityElement(children: .contain) + .accessibilityLabel("\(content.title). \(content.subtitle)") + } + + @ViewBuilder + private func trailingControls(for content: BrowserProfileImportBannerContent) -> some View { + switch content.action { + case let .importProfiles(profiles): + if profiles.count == 1, let profile = profiles.first { + Button("Import") { + Task { await self.model.importProfile(profile) } + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } else { + Menu("Import") { + ForEach(profiles, id: \.menuID) { profile in + Button(profile.displayName) { + Task { await self.model.importProfile(profile) } + } + } + } + .menuStyle(.button) + .buttonStyle(.borderedProminent) + .controlSize(.small) + .fixedSize() + } + case .retry: + Button("Retry") { + self.model.retry() + } + .buttonStyle(.bordered) + .controlSize(.small) + case .none: + if content.badge == .progress { + ProgressView() + .controlSize(.small) + } + } + } + + private func dismissButton(for content: BrowserProfileImportBannerContent) -> some View { + Button { + self.model.dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .disabled(content.badge == .progress) + .opacity(content.badge == .progress ? 0.4 : 1) + .help("Dismiss") + .accessibilityLabel("Dismiss") + } +} + +extension BrowserProfileImportBannerView { + /// Wraps the dashboard web view in a pane that floats the shared import + /// banner on top; the banner renders empty (zero height) until the model + /// has an offer or outcome to show. + @MainActor + static func makeDashboardPane(webView: NSView) -> NSView { + let container = NSView() + webView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(webView) + let banner = NSHostingView(rootView: BrowserProfileImportBannerView(model: .shared)) + banner.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(banner) + // Preferred card width; the required side insets win on narrow panes. + let bannerWidth = banner.widthAnchor.constraint(equalToConstant: 560) + bannerWidth.priority = .defaultHigh + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + webView.topAnchor.constraint(equalTo: container.topAnchor), + webView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + // Float just below the 50px titlebar clearance the native chrome + // CSS reserves so the card never collides with window controls. + banner.topAnchor.constraint(equalTo: container.topAnchor, constant: 58), + banner.centerXAnchor.constraint(equalTo: container.centerXAnchor), + banner.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 16), + bannerWidth, + ]) + return container + } +} + +/// App icon with a status badge in the corner — mirrors the familiar +/// "import from browser" banner iconography without shipping third-party logos. +private struct BannerBadgeIcon: View { + let badge: BrowserProfileImportBannerContent.Badge + + var body: some View { + Image(nsImage: NSApp.applicationIconImage ?? NSImage()) + .resizable() + .frame(width: 34, height: 34) + .overlay(alignment: .bottomTrailing) { + self.badgeSymbol + .frame(width: 16, height: 16) + .background(Circle().fill(.background)) + .offset(x: 4, y: 4) + } + .padding(.trailing, 2) + } + + @ViewBuilder + private var badgeSymbol: some View { + switch self.badge { + case .globe, .progress: + Image(systemName: "globe") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + case .success: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.green) + case .failure: + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.orange) + } + } +} diff --git a/apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift b/apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift new file mode 100644 index 000000000000..b5b033510de5 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift @@ -0,0 +1,265 @@ +import Foundation +import Observation +import OpenClawKit + +struct BrowserSystemProfile: Codable, Equatable { + let browser: String + let id: String + let name: String + let hasCookies: Bool + + var displayName: String { + "\(self.browserDisplayName) — \(self.name)" + } + + var browserDisplayName: String { + self.browser.prefix(1).uppercased() + self.browser.dropFirst() + } + + /// `id` is only unique per browser (Chrome and Brave both ship "Default"); + /// UI identity must span browsers or menu rows collide. + var menuID: String { + "\(self.browser)/\(self.id)" + } +} + +enum BrowserProfileImportDisposition: String, Codable, Equatable { + case dismissed + case imported +} + +struct BrowserProfileImportOutcome: Codable, Equatable { + let status: BrowserProfileImportDisposition +} + +struct BrowserProfileImportStatus: Codable, Equatable { + let enabled: Bool + let systemProfiles: [BrowserSystemProfile] + let state: BrowserProfileImportOutcome? + let suggestedTarget: String + + var importableProfiles: [BrowserSystemProfile] { + self.systemProfiles.filter(\.hasCookies) + } +} + +struct BrowserProfileImportResult: Codable, Equatable { + struct Counts: Codable, Equatable { + let total: Int + let imported: Int + } + + let into: String + let cookies: Counts +} + +struct BrowserProfileImportRequest: Equatable { + let method: String + let path: String + let body: [String: AnyCodable]? + let timeoutMs: Double? +} + +/// Drives the dashboard's browser-login import banner: it mirrors the +/// gateway-persisted import state and owns the offer → import → outcome flow +/// that used to live in a modal alert. +@MainActor +@Observable +final class BrowserProfileImportModel { + enum Phase: Equatable { + case hidden + case offering(BrowserProfileImportStatus) + case importing(profile: BrowserSystemProfile, target: String) + case imported(BrowserProfileImportResult) + case failed(message: String, retry: BrowserProfileImportStatus) + } + + enum ForceRefreshOutcome: Equatable { + case offering + case unavailable(title: String, message: String) + } + + typealias Transport = @MainActor (BrowserProfileImportRequest) async throws -> Data + + static let shared = BrowserProfileImportModel() + + private(set) var phase: Phase = .hidden + /// Bumped by setPhase; see refresh(force:) for the staleness contract. + @ObservationIgnored private var phaseGeneration = 0 + /// A dismissal must stick for this app session even while its + /// fire-and-forget persistence write is pending or lost — otherwise the + /// next automatic poll reads the still-null server state and re-offers. + /// Only Settings force-refresh overrides. + @ObservationIgnored private var dismissedThisSession = false + + private let transport: Transport + private let isOnboarded: @MainActor () -> Bool + private let isLocalMode: @MainActor () -> Bool + + init( + transport: @escaping Transport = BrowserProfileImportModel.gatewayTransport, + isOnboarded: @escaping @MainActor () -> Bool = { AppStateStore.shared.onboardingSeen }, + isLocalMode: @escaping @MainActor () -> Bool = { AppStateStore.shared.connectionMode == .local }) + { + self.transport = transport + self.isOnboarded = isOnboarded + self.isLocalMode = isLocalMode + } + + static func shouldOffer(status: BrowserProfileImportStatus, force: Bool) -> Bool { + status.enabled && !status.importableProfiles.isEmpty && (force || status.state == nil) + } + + /// Every phase change goes through here. The generation lets an awaited + /// status poll detect that a dismissal, import, or fresher poll happened + /// while it was in flight — "phase is .hidden again" alone cannot tell a + /// just-dismissed banner apart from one that never appeared. + private func setPhase(_ phase: Phase) { + self.phase = phase + 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. + @discardableResult + func refreshIfIdle() async -> Bool { + guard !self.dismissedThisSession, case .hidden = self.phase else { return false } + await self.refresh(force: false) + return true + } + + /// 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 { + 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.")) + } + let generation = self.phaseGeneration + do { + let status: BrowserProfileImportStatus = try await self.request( + method: "GET", + path: "/system-profile-import/status") + // The status await interleaves with user actions. Idle polls apply + // only if nothing changed since they started (a dismissal mid-poll + // 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 } + } else { + guard self.phaseGeneration == generation else { return .offering } + } + guard Self.shouldOffer(status: status, force: force) else { + self.setPhase(.hidden) + let message = status.enabled + ? String( + 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) + } + self.setPhase(.offering(status)) + return .offering + } 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) + } + } + + func importProfile(_ profile: BrowserSystemProfile) async { + guard case let .offering(status) = self.phase else { return } + self.setPhase(.importing(profile: profile, target: status.suggestedTarget)) + do { + let body: [String: AnyCodable] = [ + "browser": AnyCodable(profile.browser), + "systemProfile": AnyCodable(profile.id), + "into": AnyCodable(status.suggestedTarget), + "makeDefault": AnyCodable(true), + ] + let result: BrowserProfileImportResult = try await self.request( + method: "POST", + path: "/profiles/import", + body: body, + timeoutMs: 120_000) + self.setPhase(.imported(result)) + } catch { + self.setPhase(.failed(message: error.localizedDescription, retry: status)) + } + } + + func retry() { + guard case let .failed(_, status) = self.phase else { return } + self.setPhase(.offering(status)) + } + + func dismiss() { + let wasOffering = if case .offering = self.phase { + true + } else { + false + } + self.setPhase(.hidden) + // Only an unanswered offer persists the dismissal; closing a result + // banner must not overwrite the recorded "imported" state. + guard wasOffering else { return } + self.dismissedThisSession = true + Task { + let _: [String: Bool]? = try? await self.request( + method: "POST", + path: "/system-profile-import/dismiss") + } + } + + /// Import endpoints are host-local; a remote gateway can neither list nor + /// import system profiles, so the banner withdraws on mode switches. + func handleConnectionModeChange() { + guard !self.isLocalMode() else { return } + self.setPhase(.hidden) + } + + private func request( + method: String, + path: String, + body: [String: AnyCodable]? = nil, + timeoutMs: Double? = nil) async throws -> T + { + let data = try await self.transport(BrowserProfileImportRequest( + method: method, + path: path, + body: body, + timeoutMs: timeoutMs)) + return try JSONDecoder().decode(T.self, from: data) + } + + private static let gatewayTransport: Transport = { request in + var params: [String: AnyCodable] = [ + "method": AnyCodable(request.method), + "path": AnyCodable(request.path), + ] + if let body = request.body { + params["body"] = AnyCodable(body) + } + return try await GatewayConnection.shared.request( + method: "browser.request", + params: params, + timeoutMs: request.timeoutMs) + } +} + +#if DEBUG +extension BrowserProfileImportModel { + func _testSetPhase(_ phase: Phase) { + self.setPhase(phase) + } +} +#endif diff --git a/apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift b/apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift deleted file mode 100644 index 3ae77597b910..000000000000 --- a/apps/macos/Sources/OpenClaw/BrowserProfileImportPrompter.swift +++ /dev/null @@ -1,179 +0,0 @@ -import AppKit -import Foundation -import OpenClawKit - -struct BrowserSystemProfile: Codable, Equatable, Identifiable { - let browser: String - let id: String - let name: String - let hasCookies: Bool - - var displayName: String { - let browserName = self.browser.prefix(1).uppercased() + self.browser.dropFirst() - return "\(browserName) — \(self.name)" - } -} - -enum BrowserProfileImportDisposition: String, Codable, Equatable { - case dismissed - case imported -} - -struct BrowserProfileImportOutcome: Codable, Equatable { - let status: BrowserProfileImportDisposition -} - -struct BrowserProfileImportStatus: Codable, Equatable { - let enabled: Bool - let systemProfiles: [BrowserSystemProfile] - let state: BrowserProfileImportOutcome? - let suggestedTarget: String - - var importableProfiles: [BrowserSystemProfile] { - self.systemProfiles.filter(\.hasCookies) - } -} - -private struct BrowserProfileImportResult: Decodable { - struct Counts: Decodable { - let total: Int - let imported: Int - } - - let into: String - let cookies: Counts -} - -@MainActor -final class BrowserProfileImportPrompter { - static let shared = BrowserProfileImportPrompter() - - private var isPrompting = false - private var handledThisSession = false - - static func shouldOfferPrompt(status: BrowserProfileImportStatus, force: Bool) -> Bool { - status.enabled && !status.importableProfiles.isEmpty && (force || status.state == nil) - } - - func checkAndPromptIfNeeded(force: Bool = false) { - guard !self.isPrompting else { return } - guard force || !self.handledThisSession else { return } - guard AppStateStore.shared.onboardingSeen else { return } - guard AppStateStore.shared.connectionMode == .local else { - if force { - Self.showMessage( - title: "Browser import requires Local mode", - message: "Switch this Mac app to a local Gateway before importing browser cookies.") - } - return - } - - self.isPrompting = true - Task { @MainActor in - defer { self.isPrompting = false } - do { - let status: BrowserProfileImportStatus = try await Self.browserRequest( - method: "GET", - path: "/system-profile-import/status") - if !force { - self.handledThisSession = true - } - guard Self.shouldOfferPrompt(status: status, force: force) else { - if force { - let message = status.enabled - ? "No Chrome, Brave, Edge, or Chromium profile with cookies was found on this Mac." - : "System browser profile import is disabled in the local Gateway configuration." - Self.showMessage(title: "No browser login available", message: message) - } - return - } - await self.presentImportPrompt(status: status) - } catch { - if force { - Self.showMessage(title: "Browser import unavailable", message: error.localizedDescription) - } - } - } - } - - private func presentImportPrompt(status: BrowserProfileImportStatus) async { - let profiles = status.importableProfiles - let picker = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 28), pullsDown: false) - for profile in profiles { - picker.addItem(withTitle: profile.displayName) - } - - let targetProfile = status.suggestedTarget - let importScope = [ - "OpenClaw can copy cookies into an isolated managed profile and make it the default for agent browsing.", - "\(targetProfile) will not include local storage or IndexedDB.", - "Touch ID may be required.", - ].joined(separator: " ") - let alert = NSAlert() - alert.messageText = "Use your browser login in OpenClaw?" - alert.informativeText = importScope - alert.accessoryView = picker - alert.addButton(withTitle: "Import Cookies") - alert.addButton(withTitle: "Not Now") - - guard alert.runModal() == .alertFirstButtonReturn else { - let _: [String: Bool]? = try? await Self.browserRequest( - method: "POST", - path: "/system-profile-import/dismiss") - return - } - - let selectedIndex = max(0, picker.indexOfSelectedItem) - guard profiles.indices.contains(selectedIndex) else { return } - let profile = profiles[selectedIndex] - do { - let body: [String: AnyCodable] = [ - "browser": AnyCodable(profile.browser), - "systemProfile": AnyCodable(profile.id), - "into": AnyCodable(status.suggestedTarget), - "makeDefault": AnyCodable(true), - ] - let result: BrowserProfileImportResult = try await Self.browserRequest( - method: "POST", - path: "/profiles/import", - body: body, - timeoutMs: 120_000) - let importedSummary = - "Imported \(result.cookies.imported) of \(result.cookies.total) cookies into \(result.into). " + - "This profile is now the default for agent browsing." - Self.showMessage( - title: "Browser login imported", - message: importedSummary) - } catch { - Self.showMessage(title: "Browser import failed", message: error.localizedDescription) - } - } - - private static func browserRequest( - method: String, - path: String, - body: [String: AnyCodable]? = nil, - timeoutMs: Double? = nil) async throws -> T - { - var params: [String: AnyCodable] = [ - "method": AnyCodable(method), - "path": AnyCodable(path), - ] - if let body { - params["body"] = AnyCodable(body) - } - let data = try await GatewayConnection.shared.request( - method: "browser.request", - params: params, - timeoutMs: timeoutMs) - return try JSONDecoder().decode(T.self, from: data) - } - - private static func showMessage(title: String, message: String) { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = message - alert.addButton(withTitle: "OK") - alert.runModal() - } -} diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 88096a77b66e..7eded6ef37ec 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -114,7 +114,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, splitViewController.splitView.autosaveName = DashboardWindowLayout.linkBrowserSplitAutosaveName let dashboardViewController = NSViewController() - dashboardViewController.view = self.webView + dashboardViewController.view = BrowserProfileImportBannerView.makeDashboardPane(webView: self.webView) let dashboardItem = NSSplitViewItem(viewController: dashboardViewController) dashboardItem.minimumThickness = DashboardWindowLayout.mainBrowserMinWidth diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift index 677cb03e1ed1..93875276e4b6 100644 --- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift +++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift @@ -124,7 +124,20 @@ struct GeneralSettings: View { showsDivider: false) { Button("Import…") { - BrowserProfileImportPrompter.shared.checkAndPromptIfNeeded(force: true) + Task { @MainActor in + switch await BrowserProfileImportModel.shared.refresh(force: true) { + case .offering: + // The banner lives in the dashboard window; force + // offers must surface it even if that window is closed. + AppNavigationActions.openDashboard() + case let .unavailable(title, message): + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + alert.runModal() + } + } } .buttonStyle(.bordered) .controlSize(.small) diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index bc84aa09d8c8..199ec09474aa 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -80,7 +80,7 @@ struct OpenClawApp: App { self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) if self.controlChannel.state == .connected { DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - BrowserProfileImportPrompter.shared.checkAndPromptIfNeeded() + Task { await BrowserProfileImportModel.shared.refreshIfIdle() } } } } @@ -93,6 +93,7 @@ struct OpenClawApp: App { .onChange(of: self.state.connectionMode) { _, mode in Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) } CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode") + BrowserProfileImportModel.shared.handleConnectionModeChange() } Window("OpenClaw Settings", id: SettingsWindowOpener.windowID) { @@ -446,7 +447,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "launch") } DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) { - BrowserProfileImportPrompter.shared.checkAndPromptIfNeeded() + Task { await BrowserProfileImportModel.shared.refreshIfIdle() } } #if DEBUG diff --git a/apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportModelTests.swift b/apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportModelTests.swift new file mode 100644 index 000000000000..ac83d87c0a16 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportModelTests.swift @@ -0,0 +1,322 @@ +import Foundation +import Testing +@testable import OpenClaw + +@MainActor +private final class ContinuationBox { + var continuation: CheckedContinuation? +} + +@MainActor +private final class BrowserImportTransportStub { + struct StubError: Error, LocalizedError { + var errorDescription: String? { + "boom" + } + } + + var requests: [BrowserProfileImportRequest] = [] + var failingPaths: Set = [] + var beforeStatusResponse: (@MainActor () async -> Void)? + var statusJSON = """ + { + "enabled": true, + "systemProfiles": [ + { "browser": "chrome", "id": "Default", "name": "Personal", "hasCookies": true } + ], + "state": null, + "suggestedTarget": "imported" + } + """ + + func makeModel(isLocalMode: @escaping @MainActor () -> Bool = { true }) -> BrowserProfileImportModel { + BrowserProfileImportModel( + transport: { [weak self] request in + guard let self else { throw StubError() } + self.requests.append(request) + if request.path == "/system-profile-import/status", let hook = self.beforeStatusResponse { + self.beforeStatusResponse = nil + await hook() + } + if self.failingPaths.contains(request.path) { + throw StubError() + } + switch request.path { + case "/system-profile-import/status": + return Data(self.statusJSON.utf8) + case "/profiles/import": + return Data(#"{"into":"imported","cookies":{"total":412,"imported":409}}"#.utf8) + default: + return Data(#"{"ok":true}"#.utf8) + } + }, + isOnboarded: { true }, + isLocalMode: isLocalMode) + } + + func requests(for path: String) -> [BrowserProfileImportRequest] { + self.requests.filter { $0.path == path } + } +} + +@MainActor +struct BrowserProfileImportModelTests { + private static func status( + profiles: [BrowserSystemProfile], + enabled: Bool = true, + state: BrowserProfileImportOutcome? = nil) -> BrowserProfileImportStatus + { + BrowserProfileImportStatus( + enabled: enabled, + systemProfiles: profiles, + state: state, + suggestedTarget: "imported") + } + + private static let chromeProfile = BrowserSystemProfile( + browser: "chrome", + id: "Default", + name: "Personal", + hasCookies: true) + + @Test func `automatic offer requires importable cookies and no prior outcome`() { + let fresh = Self.status(profiles: [Self.chromeProfile]) + #expect(BrowserProfileImportModel.shouldOffer(status: fresh, force: false)) + + let dismissed = Self.status( + profiles: [Self.chromeProfile], + state: BrowserProfileImportOutcome(status: .dismissed)) + #expect(!BrowserProfileImportModel.shouldOffer(status: dismissed, force: false)) + #expect(BrowserProfileImportModel.shouldOffer(status: dismissed, force: true)) + } + + @Test func `profiles without cookies do not trigger import`() { + let status = Self.status(profiles: [ + BrowserSystemProfile(browser: "brave", id: "Default", name: "Default", hasCookies: false), + ]) + #expect(!BrowserProfileImportModel.shouldOffer(status: status, force: true)) + } + + @Test func `disabled import never offers`() { + let status = Self.status(profiles: [Self.chromeProfile], enabled: false) + #expect(!BrowserProfileImportModel.shouldOffer(status: status, force: true)) + } + + @Test func `refresh publishes the offer banner`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + let outcome = await model.refresh(force: false) + #expect(outcome == .offering) + guard case let .offering(status) = model.phase else { + Issue.record("expected offering phase, got \(model.phase)") + return + } + #expect(status.importableProfiles == [Self.chromeProfile]) + } + + @Test func `remote gateways hide the banner and report local-mode requirement`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel(isLocalMode: { false }) + let outcome = await model.refresh(force: true) + #expect(outcome == .unavailable( + title: "Browser import requires Local mode", + message: "Switch this Mac app to a local Gateway before importing browser cookies.")) + #expect(model.phase == .hidden) + #expect(stub.requests.isEmpty) + } + + @Test func `import success records counts and target`() async throws { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + await model.refresh(force: false) + await model.importProfile(Self.chromeProfile) + + let expected = BrowserProfileImportResult( + into: "imported", + cookies: BrowserProfileImportResult.Counts(total: 412, imported: 409)) + #expect(model.phase == .imported(expected)) + + let importRequest = try #require(stub.requests(for: "/profiles/import").first) + #expect(importRequest.method == "POST") + #expect(importRequest.timeoutMs == 120_000) + let body = try #require(importRequest.body) + let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(body)) as? [String: Any] + #expect(object?["browser"] as? String == "chrome") + #expect(object?["systemProfile"] as? String == "Default") + #expect(object?["into"] as? String == "imported") + #expect(object?["makeDefault"] as? Bool == true) + } + + @Test func `import failure keeps a retry path`() async { + let stub = BrowserImportTransportStub() + stub.failingPaths = ["/profiles/import"] + let model = stub.makeModel() + await model.refresh(force: false) + await model.importProfile(Self.chromeProfile) + + guard case let .failed(message, retry) = model.phase else { + Issue.record("expected failed phase, got \(model.phase)") + return + } + #expect(message == "boom") + #expect(retry.suggestedTarget == "imported") + + model.retry() + #expect(model.phase == .offering(retry)) + } + + @Test func `dismissing an offer persists the dismissal`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + await model.refresh(force: false) + model.dismiss() + #expect(model.phase == .hidden) + for _ in 0..<200 where stub.requests(for: "/system-profile-import/dismiss").isEmpty { + await Task.yield() + } + let dismissals = stub.requests(for: "/system-profile-import/dismiss") + #expect(dismissals.count == 1) + #expect(dismissals.first?.method == "POST") + } + + @Test func `dismissing a result banner does not overwrite the recorded outcome`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + await model.refresh(force: false) + await model.importProfile(Self.chromeProfile) + model.dismiss() + #expect(model.phase == .hidden) + for _ in 0..<50 { + await Task.yield() + } + #expect(stub.requests(for: "/system-profile-import/dismiss").isEmpty) + } + + @Test func `stale idle poll never clobbers a phase set while it was in flight`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + let gate = ContinuationBox() + stub.beforeStatusResponse = { + await withCheckedContinuation { gate.continuation = $0 } + } + + let idle = Task { await model.refreshIfIdle() } + while gate.continuation == nil { + await Task.yield() + } + + // A forced Settings offer lands while the idle poll is suspended. + let forced = Self.status( + profiles: [Self.chromeProfile], + state: BrowserProfileImportOutcome(status: .dismissed)) + model._testSetPhase(.offering(forced)) + + gate.continuation?.resume() + _ = await idle.value + #expect(model.phase == .offering(forced)) + } + + @Test func `dismissal suppresses automatic re-offers for the session`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + await model.refresh(force: false) + model.dismiss() + #expect(model.phase == .hidden) + + // The persistence write may still be pending or lost; the server keeps + // reporting an importable profile, but automatic polls must not re-offer. + let statusPolls = stub.requests(for: "/system-profile-import/status").count + let refreshed = await model.refreshIfIdle() + #expect(!refreshed) + #expect(stub.requests(for: "/system-profile-import/status").count == statusPolls) + #expect(model.phase == .hidden) + + // Settings force-refresh still overrides the session suppression. + let outcome = await model.refresh(force: true) + #expect(outcome == .offering) + } + + @Test func `stale idle poll does not resurrect a dismissed offer`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + let gate = ContinuationBox() + stub.beforeStatusResponse = { + await withCheckedContinuation { gate.continuation = $0 } + } + + let idle = Task { await model.refreshIfIdle() } + while gate.continuation == nil { + await Task.yield() + } + + // A faster poll offered the banner and the user dismissed it while the + // slow poll was still waiting on its (pre-dismissal) status payload. + model._testSetPhase(.offering(Self.status(profiles: [Self.chromeProfile]))) + model.dismiss() + #expect(model.phase == .hidden) + + gate.continuation?.resume() + _ = await idle.value + #expect(model.phase == .hidden) + } + + @Test func `idle refresh never clobbers a visible outcome`() async { + let stub = BrowserImportTransportStub() + let model = stub.makeModel() + await model.refresh(force: false) + await model.importProfile(Self.chromeProfile) + let requestCount = stub.requests.count + + let refreshed = await model.refreshIfIdle() + #expect(!refreshed) + #expect(stub.requests.count == requestCount) + guard case .imported = model.phase else { + Issue.record("expected imported phase, got \(model.phase)") + return + } + } +} + +@MainActor +struct BrowserProfileImportBannerContentTests { + @Test func `offering banner lists distinct browsers and offers all profiles`() throws { + let profiles = [ + BrowserSystemProfile(browser: "chrome", id: "Default", name: "Personal", hasCookies: true), + BrowserSystemProfile(browser: "chrome", id: "Profile 1", name: "Work", hasCookies: true), + BrowserSystemProfile(browser: "brave", id: "Default", name: "Default", hasCookies: true), + ] + let status = BrowserProfileImportStatus( + enabled: true, + systemProfiles: profiles, + state: nil, + suggestedTarget: "imported") + let content = try #require(BrowserProfileImportBannerContent.content(for: .offering(status))) + #expect(content.title == "Use your browser logins") + #expect(content.subtitle.contains("Chrome and Brave")) + #expect(content.badge == .globe) + #expect(content.action == .importProfiles(profiles)) + } + + @Test func `result banners carry counts and errors`() throws { + let result = BrowserProfileImportResult( + into: "imported", + cookies: BrowserProfileImportResult.Counts(total: 10, imported: 9)) + let success = try #require(BrowserProfileImportBannerContent.content(for: .imported(result))) + #expect(success.subtitle.contains("9 of 10 cookies")) + #expect(success.badge == .success) + #expect(success.action == .none) + + let status = BrowserProfileImportStatus( + enabled: true, + systemProfiles: [], + state: nil, + suggestedTarget: "imported") + let failure = try #require(BrowserProfileImportBannerContent.content( + for: .failed(message: "boom", retry: status))) + #expect(failure.subtitle == "boom") + #expect(failure.badge == .failure) + #expect(failure.action == .retry) + + #expect(BrowserProfileImportBannerContent.content(for: .hidden) == nil) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportPrompterTests.swift deleted file mode 100644 index 0b707a1a384c..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/BrowserProfileImportPrompterTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct BrowserProfileImportPrompterTests { - @Test func `automatic prompt requires importable cookies and no prior outcome`() { - let profile = BrowserSystemProfile( - browser: "chrome", - id: "Default", - name: "Personal", - hasCookies: true) - let fresh = BrowserProfileImportStatus( - enabled: true, - systemProfiles: [profile], - state: nil, - suggestedTarget: "imported") - #expect(BrowserProfileImportPrompter.shouldOfferPrompt(status: fresh, force: false)) - - let dismissed = BrowserProfileImportStatus( - enabled: true, - systemProfiles: [profile], - state: BrowserProfileImportOutcome(status: .dismissed), - suggestedTarget: "imported") - #expect(!BrowserProfileImportPrompter.shouldOfferPrompt(status: dismissed, force: false)) - #expect(BrowserProfileImportPrompter.shouldOfferPrompt(status: dismissed, force: true)) - } - - @Test func `profiles without cookies do not trigger import`() { - let status = BrowserProfileImportStatus( - enabled: true, - systemProfiles: [ - BrowserSystemProfile( - browser: "brave", - id: "Default", - name: "Default", - hasCookies: false), - ], - state: nil, - suggestedTarget: "imported") - #expect(!BrowserProfileImportPrompter.shouldOfferPrompt(status: status, force: true)) - } - - @Test func `disabled import never offers the prompt`() { - let status = BrowserProfileImportStatus( - enabled: false, - systemProfiles: [ - BrowserSystemProfile( - browser: "chrome", - id: "Default", - name: "Personal", - hasCookies: true), - ], - state: nil, - suggestedTarget: "imported") - #expect(!BrowserProfileImportPrompter.shouldOfferPrompt(status: status, force: true)) - } -} diff --git a/docs/docs_map.md b/docs/docs_map.md index 0a42ae4bc55c..ae8642916435 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5212,6 +5212,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: First run - H2: Updates - H2: Open dashboard links + - H2: Import browser logins - H2: Choose a Gateway mode - H2: What the app owns - H2: macOS detail pages diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index fac2a45fb707..85acc65e2c88 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -62,6 +62,10 @@ In the macOS app's embedded dashboard, clicking an external web link opens it in Right-click an external link to choose **Open in Sidebar**, **Open in Default Browser**, or **Copy Link**. Modified clicks and user-activated new-window links from the dashboard continue to open in the default browser; new-window links inside the sidebar open as new sidebar tabs. Regular browser-hosted Control UI pages keep the browser's normal link and context-menu behavior. +## 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. + ## Choose a Gateway mode | Mode | Use it when | Detail page |