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
This commit is contained in:
Peter Steinberger
2026-07-11 11:11:42 -07:00
committed by GitHub
parent c648ed0299
commit 9ad59ae788
11 changed files with 1054 additions and 359 deletions

View File

@@ -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",

View File

@@ -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)
}
}
}

View File

@@ -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<T: Decodable>(
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

View File

@@ -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<T: Decodable>(
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()
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,322 @@
import Foundation
import Testing
@testable import OpenClaw
@MainActor
private final class ContinuationBox {
var continuation: CheckedContinuation<Void, Never>?
}
@MainActor
private final class BrowserImportTransportStub {
struct StubError: Error, LocalizedError {
var errorDescription: String? {
"boom"
}
}
var requests: [BrowserProfileImportRequest] = []
var failingPaths: Set<String> = []
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)
}
}

View File

@@ -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))
}
}

View File

@@ -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

View File

@@ -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 |