mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-18 18:51:39 +00:00
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:
committed by
GitHub
parent
c648ed0299
commit
9ad59ae788
@@ -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",
|
||||
|
||||
246
apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift
Normal file
246
apps/macos/Sources/OpenClaw/BrowserProfileImportBannerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
265
apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift
Normal file
265
apps/macos/Sources/OpenClaw/BrowserProfileImportModel.swift
Normal 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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user