mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(macos): install and run the local Gateway automatically (#99767)
* feat(macos): automate local gateway setup * fix(macos): auto-approve the local Mac node * chore(macos): refresh generated setup metadata * chore(macos): refresh generated setup metadata * chore(macos): refresh generated setup metadata * chore(macos): refresh generated setup metadata * chore(macos): refresh generated setup metadata
This commit is contained in:
committed by
GitHub
parent
8822b66952
commit
6a7b62889e
+256
-152
@@ -12753,6 +12753,38 @@
|
||||
"surface": "apple",
|
||||
"id": "native.apple.69b64bb81b900cc4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 34,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "OpenClaw Gateway \\(version) is ready.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.cfc2509b8bbcf1d7"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 36,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "OpenClaw Gateway is not installed yet.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.9b767e46895714f9"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 38,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "The OpenClaw Gateway could not be verified. Setup will repair it.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.b4dc58a1b294e140"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 40,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Gateway \\(found) does not match app \\(required). Setup will update it.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.4088056ae7b0f3b4"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 168,
|
||||
@@ -16131,7 +16163,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 351,
|
||||
"line": 352,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Main",
|
||||
"surface": "apple",
|
||||
@@ -16139,7 +16171,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 351,
|
||||
"line": 352,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Other",
|
||||
"surface": "apple",
|
||||
@@ -16147,7 +16179,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 439,
|
||||
"line": 440,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Refreshing microphones…",
|
||||
"surface": "apple",
|
||||
@@ -16155,7 +16187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 446,
|
||||
"line": 447,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Microphone",
|
||||
"surface": "apple",
|
||||
@@ -16163,7 +16195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 467,
|
||||
"line": 468,
|
||||
"path": "apps/macos/Sources/OpenClaw/MenuContentView.swift",
|
||||
"source": "Disconnected (using System default)",
|
||||
"surface": "apple",
|
||||
@@ -16331,7 +16363,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 140,
|
||||
"line": 164,
|
||||
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
|
||||
"source": "Finish",
|
||||
"surface": "apple",
|
||||
@@ -16339,7 +16371,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 140,
|
||||
"line": 164,
|
||||
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
|
||||
"source": "Next",
|
||||
"surface": "apple",
|
||||
@@ -16347,7 +16379,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 99,
|
||||
"line": 107,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift",
|
||||
"source": "Back",
|
||||
"surface": "apple",
|
||||
@@ -16362,48 +16394,88 @@
|
||||
"id": "native.apple.ea8beaeecef15e54"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 36,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.",
|
||||
"source": "OpenClaw is your personal AI assistant. It can answer questions, work with files and apps, and connect to services like WhatsApp and Telegram through a Gateway you control.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.69f81c58c14a5df4"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 53,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Security notice",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.e75671ec2ae4bf9d"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 55,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, including running commands, reading/writing files, and capturing screenshots — depending on the permissions you grant.\n\nOnly enable OpenClaw if you understand the risks and trust the prompts and integrations you use.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.c4778e8647a5aa9f"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 75,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Choose your Gateway",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.9fb535bde270a73e"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 77,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "OpenClaw uses a single Gateway that stays running. Pick this Mac, connect to a discovered gateway nearby, or configure later.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.16dfe3ecc39e7bd5"
|
||||
"id": "native.apple.dd7411f18b0fe203"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 90,
|
||||
"line": 47,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Ask, create, and automate",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.86e4509eb0626ec8"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 48,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Give your assistant tasks and let it help across your Mac.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.a6950fed99ae450d"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 51,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Connect the tools you use",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.16ab10ed9293b718"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 52,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Bring conversations and services together in one assistant.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.687c1c2abdb8581e"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 55,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Stay in control",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.caf822c2d393914f"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 56,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Choose where the Gateway runs and which permissions OpenClaw receives.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.203bfa5e80135faa"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 62,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "OpenClaw can take actions using the permissions and services you enable. Review prompts and only connect tools you trust.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.fb64a5b187cfdc15"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 78,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Where should OpenClaw run?",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.2033d33228ce52d7"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 80,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "For the simplest setup, run the Gateway on this Mac. OpenClaw installs it and keeps it running for you.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.4791cce2ae762f19"
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 93,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "This Mac",
|
||||
"surface": "apple",
|
||||
@@ -16411,7 +16483,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 108,
|
||||
"line": 111,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Don’t start the Gateway yet.",
|
||||
"surface": "apple",
|
||||
@@ -16419,7 +16491,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 141,
|
||||
"line": 144,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Existing gateway detected",
|
||||
"surface": "apple",
|
||||
@@ -16427,7 +16499,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 141,
|
||||
"line": 144,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Port \\(probe.port) already in use",
|
||||
"surface": "apple",
|
||||
@@ -16435,7 +16507,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 143,
|
||||
"line": 146,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": " (\\(probe.command) pid \\(probe.pid))",
|
||||
"surface": "apple",
|
||||
@@ -16443,7 +16515,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 162,
|
||||
"line": 165,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Retry remote discovery (Tailscale DNS-SD + Serve probe).",
|
||||
"surface": "apple",
|
||||
@@ -16451,7 +16523,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 168,
|
||||
"line": 171,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Searching for nearby gateways…",
|
||||
"surface": "apple",
|
||||
@@ -16459,7 +16531,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 174,
|
||||
"line": 177,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Nearby gateways",
|
||||
"surface": "apple",
|
||||
@@ -16467,7 +16539,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 197,
|
||||
"line": 200,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Advanced…",
|
||||
"surface": "apple",
|
||||
@@ -16475,7 +16547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 197,
|
||||
"line": 200,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Hide Advanced",
|
||||
"surface": "apple",
|
||||
@@ -16483,7 +16555,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 217,
|
||||
"line": 220,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Transport",
|
||||
"surface": "apple",
|
||||
@@ -16491,7 +16563,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 218,
|
||||
"line": 221,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "SSH tunnel",
|
||||
"surface": "apple",
|
||||
@@ -16499,7 +16571,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 219,
|
||||
"line": 222,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Direct (ws/wss)",
|
||||
"surface": "apple",
|
||||
@@ -16507,7 +16579,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 226,
|
||||
"line": 229,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Gateway URL",
|
||||
"surface": "apple",
|
||||
@@ -16515,7 +16587,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 229,
|
||||
"line": 232,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "wss://gateway.example.ts.net",
|
||||
"surface": "apple",
|
||||
@@ -16523,7 +16595,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 236,
|
||||
"line": 239,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "SSH target",
|
||||
"surface": "apple",
|
||||
@@ -16531,7 +16603,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 256,
|
||||
"line": 259,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Identity file",
|
||||
"surface": "apple",
|
||||
@@ -16539,7 +16611,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 259,
|
||||
"line": 262,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "/Users/you/.ssh/id_ed25519",
|
||||
"surface": "apple",
|
||||
@@ -16547,7 +16619,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 264,
|
||||
"line": 267,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Project root",
|
||||
"surface": "apple",
|
||||
@@ -16555,7 +16627,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 267,
|
||||
"line": 270,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "/home/you/Projects/openclaw",
|
||||
"surface": "apple",
|
||||
@@ -16563,7 +16635,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 272,
|
||||
"line": 275,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "CLI path",
|
||||
"surface": "apple",
|
||||
@@ -16571,7 +16643,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 275,
|
||||
"line": 278,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "/Applications/OpenClaw.app/.../openclaw",
|
||||
"surface": "apple",
|
||||
@@ -16579,7 +16651,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 285,
|
||||
"line": 288,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Tip: keep Tailscale enabled so your gateway stays reachable.",
|
||||
"surface": "apple",
|
||||
@@ -16587,7 +16659,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 285,
|
||||
"line": 288,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.",
|
||||
"surface": "apple",
|
||||
@@ -16595,7 +16667,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 344,
|
||||
"line": 347,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Remote connection",
|
||||
"surface": "apple",
|
||||
@@ -16603,7 +16675,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 346,
|
||||
"line": 349,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Checks the real remote websocket and auth handshake.",
|
||||
"surface": "apple",
|
||||
@@ -16611,7 +16683,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 359,
|
||||
"line": 362,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Check connection",
|
||||
"surface": "apple",
|
||||
@@ -16619,7 +16691,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 389,
|
||||
"line": 392,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Gateway token",
|
||||
"surface": "apple",
|
||||
@@ -16627,7 +16699,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 392,
|
||||
"line": 395,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "remote gateway auth token (gateway.remote.token)",
|
||||
"surface": "apple",
|
||||
@@ -16635,7 +16707,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 396,
|
||||
"line": 399,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Used when the remote gateway requires token auth.",
|
||||
"surface": "apple",
|
||||
@@ -16643,7 +16715,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 400,
|
||||
"line": 403,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.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",
|
||||
@@ -16651,7 +16723,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 417,
|
||||
"line": 420,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Checking remote gateway…",
|
||||
"surface": "apple",
|
||||
@@ -16659,7 +16731,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 547,
|
||||
"line": 550,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": " · ssh \\(parsed.port)",
|
||||
"surface": "apple",
|
||||
@@ -16667,7 +16739,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 594,
|
||||
"line": 597,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Grant permissions",
|
||||
"surface": "apple",
|
||||
@@ -16675,7 +16747,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 596,
|
||||
"line": 599,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "These macOS permissions let OpenClaw automate apps and capture context on this Mac.",
|
||||
"surface": "apple",
|
||||
@@ -16683,7 +16755,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-modifier",
|
||||
"line": 622,
|
||||
"line": 625,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Refresh status",
|
||||
"surface": "apple",
|
||||
@@ -16691,63 +16763,71 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 635,
|
||||
"line": 638,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Install the CLI",
|
||||
"source": "Setting up OpenClaw",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.507f071d2b76be9e"
|
||||
"id": "native.apple.8d7590f1d8f61a1d"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 637,
|
||||
"line": 640,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Required for local mode: installs `openclaw` so launchd can run the gateway.",
|
||||
"source": "OpenClaw is installing the local Gateway and its managed runtime.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.5ced9b0fad1555e0"
|
||||
"id": "native.apple.e88512a0fe7fb140"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 649,
|
||||
"kind": "ui-call",
|
||||
"line": 652,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Install CLI",
|
||||
"source": "Checking existing setup…",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.fa55cb789ef49397"
|
||||
"id": "native.apple.0c05ab4b362710de"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 649,
|
||||
"kind": "ui-call",
|
||||
"line": 659,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Reinstall CLI",
|
||||
"source": "Installing the Gateway…",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.03ab2f6a2e08734d"
|
||||
"id": "native.apple.56b68eb592ecf449"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"kind": "ui-call",
|
||||
"line": 663,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Copy install command",
|
||||
"source": "Gateway installed",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.d977c700e4ece7fb"
|
||||
"id": "native.apple.80155398d97fbe63"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 669,
|
||||
"line": 672,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Installed at \\(loc)",
|
||||
"source": "Retry setup",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.e6a9668517354178"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-multiline",
|
||||
"line": 680,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Installs a user-space Node 22.19+ runtime and the CLI (no Homebrew).\nRerun anytime to reinstall or update.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.ac9f9e9da6ae6605"
|
||||
"id": "native.apple.2d2acbf2e16f8604"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 694,
|
||||
"line": 674,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Check again",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.4d58294df8d0b1e6"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 687,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Uses a private user-space install. No Terminal, administrator access, or Homebrew required.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.15cfa96c75b57b04"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 696,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Agent workspace",
|
||||
"surface": "apple",
|
||||
@@ -16755,7 +16835,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 696,
|
||||
"line": 698,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "OpenClaw runs the agent from a dedicated workspace so it can load `AGENTS.md` and write files there without mixing into your other projects.",
|
||||
"surface": "apple",
|
||||
@@ -16763,7 +16843,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 707,
|
||||
"line": 709,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Remote gateway detected",
|
||||
"surface": "apple",
|
||||
@@ -16771,7 +16851,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 709,
|
||||
"line": 711,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Create the workspace on the remote host (SSH in first). The macOS app can’t write files on your gateway over SSH yet.",
|
||||
"surface": "apple",
|
||||
@@ -16779,7 +16859,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 715,
|
||||
"line": 717,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Copied",
|
||||
"surface": "apple",
|
||||
@@ -16787,7 +16867,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 715,
|
||||
"line": 717,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Copy setup command",
|
||||
"surface": "apple",
|
||||
@@ -16795,7 +16875,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 721,
|
||||
"line": 723,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Workspace folder",
|
||||
"surface": "apple",
|
||||
@@ -16803,7 +16883,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 735,
|
||||
"line": 737,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Create workspace",
|
||||
"surface": "apple",
|
||||
@@ -16811,7 +16891,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 741,
|
||||
"line": 743,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Open folder",
|
||||
"surface": "apple",
|
||||
@@ -16819,7 +16899,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 748,
|
||||
"line": 750,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Save in config",
|
||||
"surface": "apple",
|
||||
@@ -16827,7 +16907,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 769,
|
||||
"line": 771,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. For backup, make the workspace a private git repo so your agent’s “memory” is versioned.",
|
||||
"surface": "apple",
|
||||
@@ -16835,7 +16915,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 784,
|
||||
"line": 786,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Meet your agent",
|
||||
"surface": "apple",
|
||||
@@ -16843,7 +16923,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 786,
|
||||
"line": 788,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "This is a dedicated onboarding chat. Your agent will introduce itself, learn who you are, and help you connect WhatsApp or Telegram if you want.",
|
||||
"surface": "apple",
|
||||
@@ -16851,7 +16931,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 807,
|
||||
"line": 809,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "All set",
|
||||
"surface": "apple",
|
||||
@@ -16859,7 +16939,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 812,
|
||||
"line": 814,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Configure later",
|
||||
"surface": "apple",
|
||||
@@ -16867,7 +16947,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 813,
|
||||
"line": 815,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Pick Local or Remote in Settings → General whenever you’re ready.",
|
||||
"surface": "apple",
|
||||
@@ -16875,7 +16955,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 820,
|
||||
"line": 822,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Remote gateway checklist",
|
||||
"surface": "apple",
|
||||
@@ -16883,7 +16963,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument-multiline",
|
||||
"line": 821,
|
||||
"line": 823,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.",
|
||||
"surface": "apple",
|
||||
@@ -16891,7 +16971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 830,
|
||||
"line": 832,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Open the menu bar panel",
|
||||
"surface": "apple",
|
||||
@@ -16899,7 +16979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 831,
|
||||
"line": 833,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Click the OpenClaw menu bar icon for quick chat and status.",
|
||||
"surface": "apple",
|
||||
@@ -16907,7 +16987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 834,
|
||||
"line": 836,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Connect WhatsApp or Telegram",
|
||||
"surface": "apple",
|
||||
@@ -16915,7 +16995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 835,
|
||||
"line": 837,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Open Settings → Channels to link channels and monitor status.",
|
||||
"surface": "apple",
|
||||
@@ -16923,7 +17003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 837,
|
||||
"line": 839,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Open Settings → Channels",
|
||||
"surface": "apple",
|
||||
@@ -16931,7 +17011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 842,
|
||||
"line": 844,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Try Voice Wake",
|
||||
"surface": "apple",
|
||||
@@ -16939,7 +17019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 843,
|
||||
"line": 845,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.",
|
||||
"surface": "apple",
|
||||
@@ -16947,7 +17027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 846,
|
||||
"line": 848,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Use the panel + Canvas",
|
||||
"surface": "apple",
|
||||
@@ -16955,7 +17035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 847,
|
||||
"line": 849,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Open the menu bar panel for quick chat; the agent can show previews ",
|
||||
"surface": "apple",
|
||||
@@ -16963,7 +17043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 851,
|
||||
"line": 853,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Give your agent more powers",
|
||||
"surface": "apple",
|
||||
@@ -16971,7 +17051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 852,
|
||||
"line": 854,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||
"surface": "apple",
|
||||
@@ -16979,7 +17059,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 854,
|
||||
"line": 856,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Open Settings → Skills",
|
||||
"surface": "apple",
|
||||
@@ -16987,7 +17067,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 859,
|
||||
"line": 861,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Launch at login",
|
||||
"surface": "apple",
|
||||
@@ -16995,7 +17075,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 880,
|
||||
"line": 882,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Skills included",
|
||||
"surface": "apple",
|
||||
@@ -17003,7 +17083,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 887,
|
||||
"line": 889,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Refresh",
|
||||
"surface": "apple",
|
||||
@@ -17011,7 +17091,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 896,
|
||||
"line": 898,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Couldn’t load skills from the Gateway.",
|
||||
"surface": "apple",
|
||||
@@ -17019,7 +17099,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call-concatenated",
|
||||
"line": 899,
|
||||
"line": 901,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).",
|
||||
"surface": "apple",
|
||||
@@ -17027,7 +17107,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 905,
|
||||
"line": 907,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "Details: \\(error)",
|
||||
"surface": "apple",
|
||||
@@ -17035,7 +17115,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 911,
|
||||
"line": 913,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift",
|
||||
"source": "No skills reported yet.",
|
||||
"surface": "apple",
|
||||
@@ -17059,7 +17139,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 57,
|
||||
"line": 64,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Wizard error",
|
||||
"surface": "apple",
|
||||
@@ -17067,7 +17147,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 63,
|
||||
"line": 70,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Retry",
|
||||
"surface": "apple",
|
||||
@@ -17075,7 +17155,31 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 75,
|
||||
"line": 80,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Setup is paused",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.4850230e903ad303"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 82,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Resume OpenClaw to start the local Gateway and continue setup.",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.860797a4ad8455d2"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 85,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Resume setup",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.9781249acc361d32"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 90,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Starting wizard…",
|
||||
"surface": "apple",
|
||||
@@ -17083,7 +17187,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 87,
|
||||
"line": 102,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Wizard complete. Continue to the next step.",
|
||||
"surface": "apple",
|
||||
@@ -17091,7 +17195,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 90,
|
||||
"line": 105,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift",
|
||||
"source": "Waiting for wizard…",
|
||||
"surface": "apple",
|
||||
@@ -17099,7 +17203,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 281,
|
||||
"line": 286,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift",
|
||||
"source": "Unsupported step type",
|
||||
"surface": "apple",
|
||||
@@ -17107,7 +17211,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 286,
|
||||
"line": 291,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift",
|
||||
"source": "Continue",
|
||||
"surface": "apple",
|
||||
@@ -17115,7 +17219,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 286,
|
||||
"line": 291,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift",
|
||||
"source": "Run",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -9,9 +9,24 @@ final class CLIInstallPrompter {
|
||||
private var isPrompting = false
|
||||
|
||||
func checkAndPromptIfNeeded(reason: String) {
|
||||
guard self.shouldPrompt() else { return }
|
||||
guard let version = Self.appVersion() else { return }
|
||||
guard !self.isPrompting else { return }
|
||||
self.isPrompting = true
|
||||
Task { @MainActor in
|
||||
await self.checkAndPromptIfNeededAsync(reason: reason)
|
||||
self.isPrompting = false
|
||||
}
|
||||
}
|
||||
|
||||
private func checkAndPromptIfNeededAsync(reason: String) async {
|
||||
guard AppStateStore.shared.onboardingSeen else { return }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return }
|
||||
guard let version = Self.appVersion() else { return }
|
||||
let status = await CLIInstaller.status()
|
||||
guard AppStateStore.shared.onboardingSeen else { return }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return }
|
||||
guard !status.isReady else { return }
|
||||
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||
guard lastPrompt != version else { return }
|
||||
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||
|
||||
let alert = NSAlert()
|
||||
@@ -32,22 +47,24 @@ final class CLIInstallPrompter {
|
||||
}
|
||||
|
||||
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
|
||||
self.isPrompting = false
|
||||
}
|
||||
|
||||
private func shouldPrompt() -> Bool {
|
||||
guard !self.isPrompting else { return false }
|
||||
guard AppStateStore.shared.onboardingSeen else { return false }
|
||||
guard AppStateStore.shared.connectionMode == .local else { return false }
|
||||
guard CLIInstaller.installedLocation() == nil else { return false }
|
||||
guard let version = Self.appVersion() else { return false }
|
||||
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
|
||||
return lastPrompt != version
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
let status = StatusBox()
|
||||
await CLIInstaller.install { message in
|
||||
let installed = await CLIInstaller.install { message in
|
||||
await status.set(message)
|
||||
}
|
||||
if installed {
|
||||
await status.set("Starting OpenClaw Gateway…")
|
||||
let activation = await CLIInstaller.activateLocalGateway()
|
||||
let message = switch activation {
|
||||
case .ready:
|
||||
"OpenClaw Gateway is ready."
|
||||
case .deferred:
|
||||
"OpenClaw is installed. The Gateway will start when This Mac is active and resumed."
|
||||
case .failed:
|
||||
"OpenClaw was installed, but the Gateway did not start. Open Settings to retry."
|
||||
}
|
||||
await status.set(message)
|
||||
}
|
||||
if let message = await status.get() {
|
||||
|
||||
@@ -2,16 +2,65 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
enum LocalGatewayActivation: Equatable {
|
||||
case ready
|
||||
case deferred
|
||||
case failed
|
||||
}
|
||||
|
||||
enum Status: Equatable {
|
||||
case ready(location: String, version: String)
|
||||
case missing(location: String)
|
||||
case unusable(location: String)
|
||||
case incompatible(location: String, found: String, required: String)
|
||||
|
||||
var isReady: Bool {
|
||||
if case .ready = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var location: String {
|
||||
switch self {
|
||||
case let .ready(location, _),
|
||||
let .missing(location),
|
||||
let .unusable(location),
|
||||
let .incompatible(location, _, _):
|
||||
location
|
||||
}
|
||||
}
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case let .ready(_, version):
|
||||
"OpenClaw Gateway \(version) is ready."
|
||||
case .missing:
|
||||
"OpenClaw Gateway is not installed yet."
|
||||
case .unusable:
|
||||
"The OpenClaw Gateway could not be verified. Setup will repair it."
|
||||
case let .incompatible(_, found, required):
|
||||
"Gateway \(found) does not match app \(required). Setup will update it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
self.installedLocations(
|
||||
searchPaths: CommandResolver.preferredPaths(),
|
||||
fileManager: .default)
|
||||
fileManager: .default).first
|
||||
}
|
||||
|
||||
static func installedLocation(
|
||||
searchPaths: [String],
|
||||
fileManager: FileManager) -> String?
|
||||
{
|
||||
self.installedLocations(searchPaths: searchPaths, fileManager: fileManager).first
|
||||
}
|
||||
|
||||
static func installedLocations(
|
||||
searchPaths: [String],
|
||||
fileManager: FileManager) -> [String]
|
||||
{
|
||||
var locations: [String] = []
|
||||
for basePath in searchPaths {
|
||||
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("openclaw").path
|
||||
var isDirectory: ObjCBool = false
|
||||
@@ -24,40 +73,172 @@ enum CLIInstaller {
|
||||
|
||||
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
|
||||
|
||||
return candidate
|
||||
locations.append(candidate)
|
||||
}
|
||||
|
||||
return nil
|
||||
return locations
|
||||
}
|
||||
|
||||
static func isInstalled() -> Bool {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
static func managedExecutableLocation() -> String {
|
||||
URL(fileURLWithPath: self.installPrefix())
|
||||
.appendingPathComponent("bin/openclaw")
|
||||
.path
|
||||
}
|
||||
|
||||
static func status() async -> Status {
|
||||
let locations = self.installedLocations(
|
||||
searchPaths: CommandResolver.preferredPaths(),
|
||||
fileManager: .default)
|
||||
guard !locations.isEmpty else {
|
||||
return .missing(location: self.managedExecutableLocation())
|
||||
}
|
||||
|
||||
var fallbackStatus: Status?
|
||||
for location in locations {
|
||||
let status = await self.status(location: location)
|
||||
if status.isReady {
|
||||
self.rememberValidated(status)
|
||||
return status
|
||||
}
|
||||
fallbackStatus = fallbackStatus ?? status
|
||||
}
|
||||
return fallbackStatus ?? .missing(location: self.managedExecutableLocation())
|
||||
}
|
||||
|
||||
static func managedStatus() async -> Status {
|
||||
let location = self.managedExecutableLocation()
|
||||
guard FileManager.default.isExecutableFile(atPath: location) else {
|
||||
return .missing(location: location)
|
||||
}
|
||||
|
||||
let status = await self.status(location: location)
|
||||
if status.isReady {
|
||||
self.rememberValidated(status)
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
static func status(location: String) async -> Status {
|
||||
let environment = self.probeEnvironment(location: location)
|
||||
let response = await ShellExecutor.runDetailed(
|
||||
command: [location, "--version"],
|
||||
cwd: nil,
|
||||
env: environment,
|
||||
timeout: 5)
|
||||
guard response.success else {
|
||||
return .unusable(location: location)
|
||||
}
|
||||
let versionStatus = self.classifyVersion(
|
||||
location: location,
|
||||
output: response.stdout,
|
||||
expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
|
||||
guard versionStatus.isReady else { return versionStatus }
|
||||
guard await self.runtimeIsCompatible(environment: environment) else {
|
||||
return .unusable(location: location)
|
||||
}
|
||||
return versionStatus
|
||||
}
|
||||
|
||||
private static func runtimeIsCompatible(environment: [String: String]) async -> Bool {
|
||||
let paths = environment["PATH"]?.split(separator: ":").map(String.init) ?? []
|
||||
return await Task.detached(priority: .utility) {
|
||||
if case .success = RuntimeLocator.resolve(searchPaths: paths) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}.value
|
||||
}
|
||||
|
||||
static func classifyVersion(
|
||||
location: String,
|
||||
output: String?,
|
||||
expectedVersion: String?) -> Status
|
||||
{
|
||||
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(output)
|
||||
guard let normalized, let installed = Semver.parse(normalized) else {
|
||||
return .unusable(location: location)
|
||||
}
|
||||
guard let required = Semver.parse(expectedVersion) else {
|
||||
return .ready(location: location, version: normalized)
|
||||
}
|
||||
guard installed.compatible(with: required) else {
|
||||
return .incompatible(
|
||||
location: location,
|
||||
found: normalized,
|
||||
required: expectedVersion ?? required.description)
|
||||
}
|
||||
return .ready(location: location, version: normalized)
|
||||
}
|
||||
|
||||
static func probeEnvironment(
|
||||
location: String,
|
||||
processEnvironment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
preferredPaths: [String] = CommandResolver.preferredPaths(),
|
||||
managedExecutable: String? = nil,
|
||||
managedRuntimeDirectory: String? = nil) -> [String: String]
|
||||
{
|
||||
var environment = processEnvironment
|
||||
let executableDirectory = URL(fileURLWithPath: location).deletingLastPathComponent().path
|
||||
let effectiveManagedExecutable = managedExecutable ?? self.managedExecutableLocation()
|
||||
let effectiveManagedRuntimeDirectory = managedRuntimeDirectory ?? URL(fileURLWithPath: self.installPrefix())
|
||||
.appendingPathComponent("tools/node/bin")
|
||||
.path
|
||||
let initialPaths = location == effectiveManagedExecutable
|
||||
? [executableDirectory, effectiveManagedRuntimeDirectory]
|
||||
: [executableDirectory]
|
||||
var seen = Set<String>()
|
||||
let paths = (initialPaths + preferredPaths).filter { seen.insert($0).inserted }
|
||||
environment["PATH"] = paths.joined(separator: ":")
|
||||
return environment
|
||||
}
|
||||
|
||||
private static func rememberValidated(_ status: Status) {
|
||||
guard case let .ready(location, version) = status else { return }
|
||||
UserDefaults.standard.set(location, forKey: cliValidatedExecutableKey)
|
||||
UserDefaults.standard.set(version, forKey: cliValidatedVersionKey)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing openclaw CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
guard let installerURL = Bundle.main.url(forResource: "install-cli", withExtension: "sh") else {
|
||||
await statusHandler("Install failed: installer resource is missing. Reinstall OpenClaw.")
|
||||
return false
|
||||
}
|
||||
let cmd = self.installScriptCommand(
|
||||
version: expected,
|
||||
prefix: prefix,
|
||||
scriptPath: installerURL.path)
|
||||
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
|
||||
|
||||
if response.success {
|
||||
let managedStatus = await self.managedStatus()
|
||||
guard managedStatus.isReady else {
|
||||
await statusHandler("Install failed: \(managedStatus.message)")
|
||||
return false
|
||||
}
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
let installedVersion = parsed.last { $0.event == "done" }?.version
|
||||
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
|
||||
await statusHandler(summary)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
if let error = parsed.last(where: { $0.event == "error" })?.message {
|
||||
await statusHandler("Install failed: \(error)")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fallback = response.errorMessage ?? "install failed"
|
||||
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
|
||||
return false
|
||||
}
|
||||
|
||||
private static func installPrefix() -> String {
|
||||
@@ -66,14 +247,30 @@ enum CLIInstaller {
|
||||
.path
|
||||
}
|
||||
|
||||
private static func installScriptCommand(version: String, prefix: String) -> [String] {
|
||||
let escapedVersion = self.shellEscape(version)
|
||||
let escapedPrefix = self.shellEscape(prefix)
|
||||
let script = """
|
||||
curl -fsSL https://openclaw.bot/install-cli.sh | \
|
||||
bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion)
|
||||
"""
|
||||
return ["/bin/bash", "-lc", script]
|
||||
static func installScriptCommand(version: String, prefix: String, scriptPath: String) -> [String] {
|
||||
[
|
||||
"/bin/bash",
|
||||
scriptPath,
|
||||
"--json",
|
||||
"--no-onboard",
|
||||
"--prefix",
|
||||
prefix,
|
||||
"--version",
|
||||
version,
|
||||
]
|
||||
}
|
||||
|
||||
static func activateLocalGateway(
|
||||
mode: AppState.ConnectionMode = AppStateStore.shared.connectionMode,
|
||||
paused: Bool = AppStateStore.shared.isPaused,
|
||||
start: @MainActor () -> Void = { GatewayProcessManager.shared.setActive(true) },
|
||||
waitUntilReady: @MainActor () async -> Bool = {
|
||||
await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12)
|
||||
}) async -> LocalGatewayActivation
|
||||
{
|
||||
guard mode == .local, !paused else { return .deferred }
|
||||
start()
|
||||
return await waitUntilReady() ? .ready : .failed
|
||||
}
|
||||
|
||||
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
|
||||
@@ -90,10 +287,6 @@ enum CLIInstaller {
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
private static func shellEscape(_ raw: String) -> String {
|
||||
"'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||
}
|
||||
}
|
||||
|
||||
private struct InstallEvent: Decodable {
|
||||
|
||||
@@ -74,11 +74,34 @@ enum CommandResolver {
|
||||
.split(separator: ":").map(String.init) ?? []
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
let projectRoot = self.projectRoot()
|
||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||
let validatedExecutable = self.validatedOpenClawExecutable(
|
||||
defaults: .standard,
|
||||
fileManager: .default,
|
||||
requiredVersion: GatewayEnvironment.expectedGatewayVersionString())
|
||||
return self.preferredPaths(
|
||||
home: home,
|
||||
current: current,
|
||||
projectRoot: projectRoot,
|
||||
validatedExecutable: validatedExecutable)
|
||||
}
|
||||
|
||||
static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] {
|
||||
var extras = [
|
||||
static func preferredPaths(
|
||||
home: URL,
|
||||
current: [String],
|
||||
projectRoot: URL,
|
||||
validatedExecutable: String? = nil) -> [String]
|
||||
{
|
||||
var preferredPaths: [String] = []
|
||||
let managedPaths = self.openclawManagedPaths(home: home)
|
||||
if let validatedExecutable {
|
||||
let validatedBin = URL(fileURLWithPath: validatedExecutable).deletingLastPathComponent().path
|
||||
if managedPaths.contains(validatedBin) {
|
||||
preferredPaths.append(contentsOf: managedPaths)
|
||||
} else {
|
||||
preferredPaths.append(validatedBin)
|
||||
}
|
||||
}
|
||||
let externalPaths = [
|
||||
home.appendingPathComponent("Library/pnpm").path,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
@@ -87,16 +110,27 @@ enum CommandResolver {
|
||||
]
|
||||
#if DEBUG
|
||||
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
|
||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
preferredPaths.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
#endif
|
||||
let openclawPaths = self.openclawManagedPaths(home: home)
|
||||
if !openclawPaths.isEmpty {
|
||||
extras.insert(contentsOf: openclawPaths, at: 1)
|
||||
}
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + openclawPaths.count)
|
||||
var seen = Set<String>()
|
||||
let fallbackPaths = self.nodeManagerBinPaths(home: home) + externalPaths + managedPaths
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
return (preferredPaths + fallbackPaths + current).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
static func validatedOpenClawExecutable(
|
||||
defaults: UserDefaults,
|
||||
fileManager: FileManager,
|
||||
requiredVersion: String?) -> String?
|
||||
{
|
||||
guard let executable = defaults.string(forKey: cliValidatedExecutableKey),
|
||||
fileManager.isExecutableFile(atPath: executable),
|
||||
let validatedVersion = Semver.parse(defaults.string(forKey: cliValidatedVersionKey))
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let required = Semver.parse(requiredVersion) else { return executable }
|
||||
return validatedVersion.compatible(with: required) ? executable : nil
|
||||
}
|
||||
|
||||
private static func openclawManagedPaths(home: URL) -> [String] {
|
||||
|
||||
@@ -42,6 +42,9 @@ let locationPreciseKey = "openclaw.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "openclaw.deepLinkKey"
|
||||
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
|
||||
let cliValidatedExecutableKey = "openclaw.cliValidatedExecutable"
|
||||
let cliValidatedVersionKey = "openclaw.cliValidatedVersion"
|
||||
let macNodeIdentityProfileKey = "openclaw.macNodeIdentityProfile"
|
||||
let heartbeatsEnabledKey = "openclaw.heartbeatsEnabled"
|
||||
let debugPaneEnabledKey = "openclaw.debugPaneEnabled"
|
||||
let debugFileLogEnabledKey = "openclaw.debug.fileLogEnabled"
|
||||
|
||||
@@ -357,9 +357,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
}
|
||||
self.state = AppStateStore.shared
|
||||
if let state {
|
||||
MacNodeModeCoordinator.prepareNodeIdentityProfile(
|
||||
isExistingInstallation: state.onboardingSeen || state.connectionMode != .unconfigured)
|
||||
}
|
||||
AppActivationPolicy.apply(showDockIcon: self.state?.showDockIcon ?? false)
|
||||
if let state {
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: state.connectionMode, paused: state.isPaused) }
|
||||
Task {
|
||||
// Validate PATH selection before local startup. Existing installs may not
|
||||
// have the validation cache yet, and a stale external CLI must not win.
|
||||
if state.connectionMode == .local {
|
||||
_ = await CLIInstaller.status()
|
||||
}
|
||||
await ConnectionModeCoordinator.shared.apply(
|
||||
mode: state.connectionMode,
|
||||
paused: state.isPaused)
|
||||
}
|
||||
}
|
||||
TerminationSignalWatcher.shared.start()
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
@@ -419,7 +432,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@MainActor
|
||||
private func scheduleFirstRunOnboardingIfNeeded() {
|
||||
if AppStateStore.shared.connectionMode != .unconfigured {
|
||||
if AppStateStore.shared.connectionMode != .unconfigured,
|
||||
AppStateStore.shared.onboardingSeen
|
||||
{
|
||||
OnboardingController.markComplete()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -324,7 +324,8 @@ struct MenuContent: View {
|
||||
guard self.state.connectionMode != .unconfigured else { return nil }
|
||||
guard case .connected = self.controlChannel.state else { return nil }
|
||||
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
|
||||
let deviceId = DeviceIdentityStore.loadOrCreate(
|
||||
profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId
|
||||
if let entry = self.nodesStore.nodes.first(where: { $0.nodeId == deviceId }) {
|
||||
guard entry.isConnected else {
|
||||
return ("Mac capabilities offline", .orange)
|
||||
|
||||
@@ -42,6 +42,34 @@ struct MacNodeGatewayTLSSessionCache {
|
||||
@MainActor
|
||||
final class MacNodeModeCoordinator {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
static var nodeIdentityProfile: GatewayDeviceIdentityProfile {
|
||||
self.resolveNodeIdentityProfile(
|
||||
defaults: .standard,
|
||||
isExistingInstallation: AppStateStore.shared.onboardingSeen)
|
||||
}
|
||||
|
||||
static func prepareNodeIdentityProfile(isExistingInstallation: Bool) {
|
||||
_ = self.resolveNodeIdentityProfile(
|
||||
defaults: .standard,
|
||||
isExistingInstallation: isExistingInstallation)
|
||||
}
|
||||
|
||||
static func resolveNodeIdentityProfile(
|
||||
defaults: UserDefaults,
|
||||
isExistingInstallation: Bool) -> GatewayDeviceIdentityProfile
|
||||
{
|
||||
if let rawValue = defaults.string(forKey: macNodeIdentityProfileKey),
|
||||
let stored = GatewayDeviceIdentityProfile(rawValue: rawValue),
|
||||
stored == .primary || stored == .node
|
||||
{
|
||||
return stored
|
||||
}
|
||||
// Released builds used the primary identity for the Mac node. Persist the
|
||||
// install-era choice before onboarding can change connection state.
|
||||
let selected: GatewayDeviceIdentityProfile = isExistingInstallation ? .primary : .node
|
||||
defaults.set(selected.rawValue, forKey: macNodeIdentityProfileKey)
|
||||
return selected
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "mac-node")
|
||||
private var task: Task<Void, Never>?
|
||||
@@ -120,7 +148,8 @@ final class MacNodeModeCoordinator {
|
||||
permissions: permissions,
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
clientDisplayName: InstanceIdentity.displayName,
|
||||
deviceIdentityProfile: Self.nodeIdentityProfile)
|
||||
let sessionBox = self.buildSessionBox(
|
||||
url: config.url,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
|
||||
@@ -279,7 +279,7 @@ final class NodePairingApprovalPrompter {
|
||||
self.isPresenting = true
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if await self.trySilentApproveIfPossible(next) {
|
||||
if await self.tryAutomaticApproveIfPossible(next) {
|
||||
return
|
||||
}
|
||||
self.presentAlert(for: next)
|
||||
@@ -409,10 +409,20 @@ final class NodePairingApprovalPrompter {
|
||||
let port: Int
|
||||
}
|
||||
|
||||
private func trySilentApproveIfPossible(_ req: PendingRequest) async -> Bool {
|
||||
private func tryAutomaticApproveIfPossible(_ req: PendingRequest) async -> Bool {
|
||||
let localNodeId = DeviceIdentityStore.loadOrCreate(
|
||||
profile: MacNodeModeCoordinator.nodeIdentityProfile).deviceId
|
||||
if Self.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: AppStateStore.shared.connectionMode,
|
||||
requestNodeId: req.nodeId,
|
||||
localNodeId: localNodeId)
|
||||
{
|
||||
guard self.beginAutoApproveAttempt(requestId: req.requestId) else { return false }
|
||||
return await self.approveAutomatically(req, via: "local-node", notify: false)
|
||||
}
|
||||
|
||||
guard req.silent == true else { return false }
|
||||
if self.autoApproveAttempts.contains(req.requestId) { return false }
|
||||
self.autoApproveAttempts.insert(req.requestId)
|
||||
guard self.beginAutoApproveAttempt(requestId: req.requestId) else { return false }
|
||||
|
||||
guard let target = await self.resolveSSHTarget() else {
|
||||
self.logger.info("silent pairing skipped (no ssh target) requestId=\(req.requestId, privacy: .public)")
|
||||
@@ -431,12 +441,20 @@ final class NodePairingApprovalPrompter {
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.approveAutomatically(req, via: "silent-ssh", notify: true)
|
||||
}
|
||||
|
||||
private func approveAutomatically(_ req: PendingRequest, via: String, notify: Bool) async -> Bool {
|
||||
guard await self.approve(requestId: req.requestId) else {
|
||||
self.logger.info("silent pairing approve failed requestId=\(req.requestId, privacy: .public)")
|
||||
self.logger.info("automatic pairing approve failed requestId=\(req.requestId, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
await self.notify(resolution: .approved, request: req, via: "silent-ssh")
|
||||
self.logger.info(
|
||||
"automatically approved node pairing requestId=\(req.requestId, privacy: .public) via=\(via, privacy: .public)")
|
||||
if notify {
|
||||
await self.notify(resolution: .approved, request: req, via: via)
|
||||
}
|
||||
if self.queue.first == req {
|
||||
self.queue.removeFirst()
|
||||
} else {
|
||||
@@ -450,6 +468,20 @@ final class NodePairingApprovalPrompter {
|
||||
return true
|
||||
}
|
||||
|
||||
private func beginAutoApproveAttempt(requestId: String) -> Bool {
|
||||
self.autoApproveAttempts.insert(requestId).inserted
|
||||
}
|
||||
|
||||
static func shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
requestNodeId: String,
|
||||
localNodeId: String) -> Bool
|
||||
{
|
||||
// The signed node identity is the same app-owned node already connecting to this Mac's Gateway.
|
||||
// Keep remote and mismatched identities on the explicit approval path.
|
||||
connectionMode == .local && requestNodeId == localNodeId
|
||||
}
|
||||
|
||||
private func resolveSSHTarget() async -> SSHTarget? {
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
if !settings.target.isEmpty, let parsed = CommandResolver.parseSSHTarget(settings.target) {
|
||||
@@ -632,7 +664,7 @@ extension NodePairingApprovalPrompter {
|
||||
|
||||
prompter.queue = [pending]
|
||||
_ = prompter.shouldPoll
|
||||
_ = await prompter.trySilentApproveIfPossible(pending)
|
||||
_ = await prompter.tryAutomaticApproveIfPossible(pending)
|
||||
prompter.queue.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,10 @@ final class OnboardingController {
|
||||
self.window = nil
|
||||
}
|
||||
|
||||
func setWindowCloseEnabled(_ enabled: Bool) {
|
||||
self.window?.standardWindowButton(.closeButton)?.isEnabled = enabled
|
||||
}
|
||||
|
||||
func restart() {
|
||||
self.close()
|
||||
self.show()
|
||||
@@ -74,6 +78,8 @@ struct OnboardingView: View {
|
||||
@State var monitoringPermissions = false
|
||||
@State var monitoringDiscovery = false
|
||||
@State var cliInstalled = false
|
||||
@State var cliStatusKnown = false
|
||||
@State var onboardingVisible = false
|
||||
@State var cliInstallLocation: String?
|
||||
@State var workspacePath: String = ""
|
||||
@State var workspaceStatus: String?
|
||||
@@ -91,6 +97,7 @@ struct OnboardingView: View {
|
||||
@State var onboardingWizard = OnboardingWizardModel()
|
||||
@State var didLoadOnboardingSkills = false
|
||||
@State var localGatewayProbe: LocalGatewayProbe?
|
||||
@State var defaultsToLocalGateway: Bool
|
||||
@Bindable var state: AppState
|
||||
var permissionMonitor: PermissionMonitor
|
||||
|
||||
@@ -100,23 +107,26 @@ struct OnboardingView: View {
|
||||
let pageWidth: CGFloat = Self.windowWidth
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let cliPageIndex = 2
|
||||
let wizardPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
let permissionsPageIndex = 5
|
||||
static func pageOrder(
|
||||
for mode: AppState.ConnectionMode,
|
||||
showOnboardingChat: Bool) -> [Int]
|
||||
showOnboardingChat: Bool,
|
||||
requiresCLIInstall: Bool) -> [Int]
|
||||
{
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||
// and WhatsApp/Telegram setup is optional.
|
||||
showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
|
||||
return showOnboardingChat ? [0, 1, 5, 8, 9] : [0, 1, 5, 9]
|
||||
case .unconfigured:
|
||||
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
return showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
case .local:
|
||||
showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
|
||||
let setupPages = requiresCLIInstall ? [0, 1, 2, 3, 5] : [0, 1, 3, 5]
|
||||
return showOnboardingChat ? setupPages + [8, 9] : setupPages + [9]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +134,22 @@ struct OnboardingView: View {
|
||||
self.state.connectionMode == .local && self.needsBootstrap
|
||||
}
|
||||
|
||||
var selectedConnectionMode: AppState.ConnectionMode {
|
||||
if self.isConnectionSelectionBlocking {
|
||||
return .local
|
||||
}
|
||||
return self.state.connectionMode
|
||||
}
|
||||
|
||||
var isConnectionSelectionBlocking: Bool {
|
||||
self.defaultsToLocalGateway && self.state.connectionMode == .unconfigured
|
||||
}
|
||||
|
||||
var pageOrder: [Int] {
|
||||
Self.pageOrder(for: self.state.connectionMode, showOnboardingChat: self.showOnboardingChat)
|
||||
Self.pageOrder(
|
||||
for: self.state.connectionMode,
|
||||
showOnboardingChat: self.showOnboardingChat,
|
||||
requiresCLIInstall: self.state.connectionMode == .local && !self.cliInstalled)
|
||||
}
|
||||
|
||||
var pageCount: Int {
|
||||
@@ -148,13 +172,12 @@ struct OnboardingView: View {
|
||||
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
||||
}
|
||||
|
||||
var canAdvance: Bool {
|
||||
!self.isWizardBlocking
|
||||
var isCLIBlocking: Bool {
|
||||
self.activePageIndex == self.cliPageIndex && !self.cliInstalled
|
||||
}
|
||||
|
||||
var devLinkCommand: String {
|
||||
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
return "npm install -g openclaw@\(version)"
|
||||
var canAdvance: Bool {
|
||||
!self.isCLIBlocking && !self.isWizardBlocking
|
||||
}
|
||||
|
||||
struct LocalGatewayProbe: Equatable {
|
||||
@@ -173,6 +196,8 @@ struct OnboardingView: View {
|
||||
{
|
||||
self.state = state
|
||||
self.permissionMonitor = permissionMonitor
|
||||
self._defaultsToLocalGateway = State(
|
||||
initialValue: !state.onboardingSeen && state.connectionMode == .unconfigured)
|
||||
self._gatewayDiscovery = State(initialValue: discoveryModel)
|
||||
self._onboardingChatModel = State(
|
||||
initialValue: OpenClawChatViewModel(
|
||||
|
||||
@@ -6,6 +6,7 @@ import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
func selectLocalGateway() {
|
||||
self.defaultsToLocalGateway = false
|
||||
self.state.connectionMode = .local
|
||||
self.preferredGatewayID = nil
|
||||
self.showAdvancedConnection = false
|
||||
@@ -13,6 +14,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func selectUnconfiguredGateway() {
|
||||
self.defaultsToLocalGateway = false
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
self.state.connectionMode = .unconfigured
|
||||
self.preferredGatewayID = nil
|
||||
@@ -21,6 +23,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
self.defaultsToLocalGateway = false
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
@@ -42,6 +45,7 @@ extension OnboardingView {
|
||||
|
||||
func handleNext() {
|
||||
if self.isWizardBlocking { return }
|
||||
self.commitRecommendedConnectionIfNeeded(for: self.activePageIndex)
|
||||
if self.currentPage < self.pageCount - 1 {
|
||||
withAnimation { self.currentPage += 1 }
|
||||
} else {
|
||||
@@ -49,6 +53,15 @@ extension OnboardingView {
|
||||
}
|
||||
}
|
||||
|
||||
func commitRecommendedConnectionIfNeeded(for pageIndex: Int) {
|
||||
if pageIndex == self.connectionPageIndex,
|
||||
self.defaultsToLocalGateway,
|
||||
self.state.connectionMode == .unconfigured
|
||||
{
|
||||
self.selectLocalGateway()
|
||||
}
|
||||
}
|
||||
|
||||
func finish() {
|
||||
OnboardingController.markComplete()
|
||||
OnboardingController.shared.close()
|
||||
|
||||
@@ -27,37 +27,43 @@ extension OnboardingView {
|
||||
Spacer(minLength: 0)
|
||||
self.navigationBar
|
||||
}
|
||||
.frame(width: self.pageWidth, height: Self.windowHeight)
|
||||
.frame(width: pageWidth, height: Self.windowHeight)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
.onAppear {
|
||||
self.onboardingVisible = true
|
||||
self.currentPage = 0
|
||||
self.updateMonitoring(for: 0)
|
||||
}
|
||||
.onChange(of: self.currentPage) { _, newValue in
|
||||
.onChange(of: currentPage) { _, newValue in
|
||||
self.updateMonitoring(for: self.activePageIndex(for: newValue))
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, _ in
|
||||
.onChange(of: state.connectionMode) { _, _ in
|
||||
let oldActive = self.activePageIndex
|
||||
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
||||
self.updateDiscoveryMonitoring(for: self.activePageIndex)
|
||||
}
|
||||
.onChange(of: self.needsBootstrap) { _, _ in
|
||||
.onChange(of: needsBootstrap) { _, _ in
|
||||
if self.currentPage >= self.pageOrder.count {
|
||||
self.currentPage = max(0, self.pageOrder.count - 1)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.onboardingWizard.isComplete) { _, newValue in
|
||||
.onChange(of: cliInstalled) { _, installed in
|
||||
guard installed else { return }
|
||||
self.updateMonitoring(for: self.activePageIndex)
|
||||
}
|
||||
.onChange(of: onboardingWizard.isComplete) { _, newValue in
|
||||
guard newValue, self.activePageIndex == self.wizardPageIndex else { return }
|
||||
self.handleNext()
|
||||
}
|
||||
.onDisappear {
|
||||
self.onboardingVisible = false
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
}
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
await self.refreshCLIStatus()
|
||||
await self.loadWorkspaceDefaults()
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshBootstrapStatus()
|
||||
@@ -66,17 +72,17 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func activePageIndex(for pageCursor: Int) -> Int {
|
||||
guard !self.pageOrder.isEmpty else { return 0 }
|
||||
let clamped = min(max(0, pageCursor), self.pageOrder.count - 1)
|
||||
return self.pageOrder[clamped]
|
||||
guard !pageOrder.isEmpty else { return 0 }
|
||||
let clamped = min(max(0, pageCursor), pageOrder.count - 1)
|
||||
return pageOrder[clamped]
|
||||
}
|
||||
|
||||
func reconcilePageForModeChange(previousActivePageIndex: Int) {
|
||||
if let exact = self.pageOrder.firstIndex(of: previousActivePageIndex) {
|
||||
if let exact = pageOrder.firstIndex(of: previousActivePageIndex) {
|
||||
withAnimation { self.currentPage = exact }
|
||||
return
|
||||
}
|
||||
if let next = self.pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) {
|
||||
if let next = pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) {
|
||||
withAnimation { self.currentPage = next }
|
||||
return
|
||||
}
|
||||
@@ -84,7 +90,9 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
var navigationBar: some View {
|
||||
let wizardLockIndex = self.wizardPageOrderIndex
|
||||
let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex)
|
||||
let wizardLockIndex = wizardPageOrderIndex
|
||||
let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex)
|
||||
return HStack(spacing: 20) {
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: {}, label: {
|
||||
@@ -102,6 +110,7 @@ extension OnboardingView {
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(0.8)
|
||||
.disabled(self.installingCLI)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
}
|
||||
@@ -111,7 +120,11 @@ extension OnboardingView {
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(0..<self.pageCount, id: \.self) { index in
|
||||
let isLocked = wizardLockIndex != nil && !self.onboardingWizard
|
||||
let isInstallLocked = self.installingCLI && index != self.currentPage
|
||||
let isConnectionLocked = self.isConnectionSelectionBlocking &&
|
||||
index > (connectionLockIndex ?? 0)
|
||||
let isCLILocked = cliLockIndex != nil && !self.cliInstalled && index > (cliLockIndex ?? 0)
|
||||
let isWizardLocked = wizardLockIndex != nil && !self.onboardingWizard
|
||||
.isComplete && index > (wizardLockIndex ?? 0)
|
||||
Button {
|
||||
withAnimation { self.currentPage = index }
|
||||
@@ -121,8 +134,8 @@ extension OnboardingView {
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isLocked)
|
||||
.opacity(isLocked ? 0.3 : 1)
|
||||
.disabled(isInstallLocked || isConnectionLocked || isCLILocked || isWizardLocked)
|
||||
.opacity(isInstallLocked || isConnectionLocked || isCLILocked || isWizardLocked ? 0.3 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +166,7 @@ extension OnboardingView {
|
||||
}
|
||||
.scrollIndicators(.automatic)
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
.frame(width: pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
func onboardingCard(
|
||||
|
||||
@@ -4,13 +4,13 @@ import OpenClawIPC
|
||||
extension OnboardingView {
|
||||
@MainActor
|
||||
func refreshPerms() async {
|
||||
await self.permissionMonitor.refreshNow()
|
||||
await permissionMonitor.refreshNow()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func request(_ cap: Capability) async {
|
||||
guard !self.isRequesting else { return }
|
||||
self.isRequesting = true
|
||||
guard !isRequesting else { return }
|
||||
isRequesting = true
|
||||
defer { isRequesting = false }
|
||||
_ = await PermissionManager.ensure([cap], interactive: true)
|
||||
await self.refreshPerms()
|
||||
@@ -18,57 +18,107 @@ extension OnboardingView {
|
||||
|
||||
func updatePermissionMonitoring(for pageIndex: Int) {
|
||||
PermissionMonitoringSupport.setMonitoring(
|
||||
pageIndex == self.permissionsPageIndex,
|
||||
monitoring: &self.monitoringPermissions)
|
||||
pageIndex == permissionsPageIndex,
|
||||
monitoring: &monitoringPermissions)
|
||||
}
|
||||
|
||||
func updateDiscoveryMonitoring(for pageIndex: Int) {
|
||||
let isConnectionPage = pageIndex == self.connectionPageIndex
|
||||
let isConnectionPage = pageIndex == connectionPageIndex
|
||||
let shouldMonitor = isConnectionPage
|
||||
if shouldMonitor, !self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = true
|
||||
if shouldMonitor, !monitoringDiscovery {
|
||||
monitoringDiscovery = true
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
guard self.monitoringDiscovery else { return }
|
||||
self.gatewayDiscovery.start()
|
||||
await self.refreshLocalGatewayProbe()
|
||||
}
|
||||
} else if !shouldMonitor, self.monitoringDiscovery {
|
||||
self.monitoringDiscovery = false
|
||||
self.gatewayDiscovery.stop()
|
||||
} else if !shouldMonitor, monitoringDiscovery {
|
||||
monitoringDiscovery = false
|
||||
gatewayDiscovery.stop()
|
||||
}
|
||||
}
|
||||
|
||||
func updateMonitoring(for pageIndex: Int) {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||
self.maybeInstallCLI(for: pageIndex)
|
||||
maybeKickoffOnboardingChat(for: pageIndex)
|
||||
}
|
||||
|
||||
func maybeInstallCLI(for pageIndex: Int) {
|
||||
guard Self.shouldAutoInstallCLI(
|
||||
onCLIPage: pageIndex == cliPageIndex,
|
||||
isLocal: state.connectionMode == .local,
|
||||
visible: onboardingVisible,
|
||||
statusKnown: cliStatusKnown,
|
||||
installed: cliInstalled,
|
||||
installing: installingCLI)
|
||||
else { return }
|
||||
self.startCLIInstall()
|
||||
}
|
||||
|
||||
static func shouldAutoInstallCLI(
|
||||
onCLIPage: Bool,
|
||||
isLocal: Bool,
|
||||
visible: Bool,
|
||||
statusKnown: Bool,
|
||||
installed: Bool,
|
||||
installing: Bool) -> Bool
|
||||
{
|
||||
onCLIPage && isLocal && visible && statusKnown && !installed && !installing
|
||||
}
|
||||
|
||||
func startCLIInstall() {
|
||||
guard self.onboardingVisible, !installingCLI else { return }
|
||||
installingCLI = true
|
||||
OnboardingController.shared.setWindowCloseEnabled(false)
|
||||
Task { @MainActor in await self.runCLIInstall() }
|
||||
}
|
||||
|
||||
func stopPermissionMonitoring() {
|
||||
PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions)
|
||||
PermissionMonitoringSupport.stopMonitoring(&monitoringPermissions)
|
||||
}
|
||||
|
||||
func stopDiscovery() {
|
||||
guard self.monitoringDiscovery else { return }
|
||||
self.monitoringDiscovery = false
|
||||
self.gatewayDiscovery.stop()
|
||||
guard monitoringDiscovery else { return }
|
||||
monitoringDiscovery = false
|
||||
gatewayDiscovery.stop()
|
||||
}
|
||||
|
||||
func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
self.installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
func runCLIInstall() async {
|
||||
defer {
|
||||
self.installingCLI = false
|
||||
OnboardingController.shared.setWindowCloseEnabled(true)
|
||||
}
|
||||
let installed = await CLIInstaller.install { message in
|
||||
self.cliStatus = message
|
||||
}
|
||||
self.refreshCLIStatus()
|
||||
guard installed else { return }
|
||||
cliInstallLocation = CLIInstaller.managedExecutableLocation()
|
||||
cliStatus = "Starting OpenClaw Gateway…"
|
||||
switch await CLIInstaller.activateLocalGateway() {
|
||||
case .ready:
|
||||
cliStatus = "OpenClaw Gateway is ready."
|
||||
case .deferred:
|
||||
cliStatus = "OpenClaw is installed. The Gateway will start when This Mac is active and resumed."
|
||||
case .failed:
|
||||
cliStatus = "OpenClaw was installed, but the Gateway did not start. Retry setup."
|
||||
return
|
||||
}
|
||||
cliInstalled = true
|
||||
}
|
||||
|
||||
func refreshCLIStatus() {
|
||||
let installLocation = CLIInstaller.installedLocation()
|
||||
self.cliInstallLocation = installLocation
|
||||
self.cliInstalled = installLocation != nil
|
||||
func refreshCLIStatus() async {
|
||||
let status = await CLIInstaller.status()
|
||||
// A startup probe may still be running when the user reaches the install page.
|
||||
// Never let that stale result replace live installation progress.
|
||||
guard self.onboardingVisible, !Task.isCancelled, !installingCLI else { return }
|
||||
cliInstallLocation = status.location
|
||||
cliInstalled = status.isReady
|
||||
cliStatusKnown = true
|
||||
cliStatus = status.message
|
||||
self.maybeInstallCLI(for: self.activePageIndex)
|
||||
}
|
||||
|
||||
func refreshLocalGatewayProbe() async {
|
||||
|
||||
@@ -13,12 +13,12 @@ extension OnboardingView {
|
||||
self.welcomePage()
|
||||
case 1:
|
||||
self.connectionPage()
|
||||
case 2:
|
||||
self.cliPage()
|
||||
case 3:
|
||||
self.wizardPage()
|
||||
case 5:
|
||||
self.permissionsPage()
|
||||
case 6:
|
||||
self.cliPage()
|
||||
case 8:
|
||||
self.onboardingChatPage()
|
||||
case 9:
|
||||
@@ -30,53 +30,56 @@ extension OnboardingView {
|
||||
|
||||
func welcomePage() -> some View {
|
||||
self.onboardingPage {
|
||||
VStack(spacing: 22) {
|
||||
VStack(spacing: 18) {
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.")
|
||||
Text(
|
||||
"OpenClaw is your personal AI assistant. It can answer questions, work with files and apps, " +
|
||||
"and connect to services like WhatsApp and Telegram through a Gateway you control.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: 560)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10, padding: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color(nsColor: .systemOrange))
|
||||
.frame(width: 22)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Security notice")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " +
|
||||
"including running commands, reading/writing files, and capturing screenshots — " +
|
||||
"depending on the permissions you grant.\n\n" +
|
||||
"Only enable OpenClaw if you understand the risks and trust the prompts and " +
|
||||
"integrations you use.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
self.onboardingCard(spacing: 14, padding: 16) {
|
||||
self.featureRow(
|
||||
title: "Ask, create, and automate",
|
||||
subtitle: "Give your assistant tasks and let it help across your Mac.",
|
||||
systemImage: "sparkles")
|
||||
self.featureRow(
|
||||
title: "Connect the tools you use",
|
||||
subtitle: "Bring conversations and services together in one assistant.",
|
||||
systemImage: "point.3.connected.trianglepath.dotted")
|
||||
self.featureRow(
|
||||
title: "Stay in control",
|
||||
subtitle: "Choose where the Gateway runs and which permissions OpenClaw receives.",
|
||||
systemImage: "hand.raised.fill")
|
||||
}
|
||||
.frame(maxWidth: 520)
|
||||
|
||||
Label {
|
||||
Text(
|
||||
"OpenClaw can take actions using the permissions and services you enable. " +
|
||||
"Review prompts and only connect tools you trust.")
|
||||
} icon: {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: 500, alignment: .leading)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
func connectionPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Choose your Gateway")
|
||||
Text("Where should OpenClaw run?")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text(
|
||||
"OpenClaw uses a single Gateway that stays running. Pick this Mac, " +
|
||||
"connect to a discovered gateway nearby, or configure later.")
|
||||
"For the simplest setup, run the Gateway on this Mac. " +
|
||||
"OpenClaw installs it and keeps it running for you.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -89,7 +92,7 @@ extension OnboardingView {
|
||||
self.connectionChoiceButton(
|
||||
title: "This Mac",
|
||||
subtitle: self.localGatewaySubtitle,
|
||||
selected: self.state.connectionMode == .local)
|
||||
selected: self.selectedConnectionMode == .local)
|
||||
{
|
||||
self.selectLocalGateway()
|
||||
}
|
||||
@@ -106,7 +109,7 @@ extension OnboardingView {
|
||||
self.connectionChoiceButton(
|
||||
title: "Configure later",
|
||||
subtitle: "Don’t start the Gateway yet.",
|
||||
selected: self.state.connectionMode == .unconfigured)
|
||||
selected: self.selectedConnectionMode == .unconfigured)
|
||||
{
|
||||
self.selectUnconfiguredGateway()
|
||||
}
|
||||
@@ -135,7 +138,7 @@ extension OnboardingView {
|
||||
|
||||
private var localGatewaySubtitle: String {
|
||||
guard let probe = self.localGatewayProbe else {
|
||||
return "Gateway starts automatically on this Mac."
|
||||
return "Recommended — installs and starts automatically."
|
||||
}
|
||||
let base = probe.expected
|
||||
? "Existing gateway detected"
|
||||
@@ -632,9 +635,9 @@ extension OnboardingView {
|
||||
|
||||
func cliPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Install the CLI")
|
||||
Text("Setting up OpenClaw")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Required for local mode: installs `openclaw` so launchd can run the gateway.")
|
||||
Text("OpenClaw is installing the local Gateway and its managed runtime.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -642,33 +645,36 @@ extension OnboardingView {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.installingCLI ? 0 : 1)
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
if !self.cliStatusKnown {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking existing setup…")
|
||||
.font(.headline)
|
||||
}
|
||||
} else if self.installingCLI {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Installing the Gateway…")
|
||||
.font(.headline)
|
||||
}
|
||||
} else if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Gateway installed", systemImage: "checkmark.circle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.green)
|
||||
Text(loc)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
} else {
|
||||
HStack {
|
||||
Button("Retry setup", action: self.startCLIInstall)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Button("Check again") {
|
||||
Task { await self.refreshCLIStatus() }
|
||||
}
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
Button(self.copied ? "Copied" : "Copy install command") {
|
||||
self.copyToPasteboard(self.devLinkCommand)
|
||||
}
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,15 +682,11 @@ extension OnboardingView {
|
||||
Text(cliStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
Installs a user-space Node 22.19+ runtime and the CLI (no Homebrew).
|
||||
Rerun anytime to reinstall or update.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("Uses a private user-space install. No Terminal, administrator access, or Homebrew required.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,13 @@ extension OnboardingView {
|
||||
OnboardingWizardCardContent(
|
||||
wizard: self.onboardingWizard,
|
||||
mode: self.state.connectionMode,
|
||||
workspacePath: self.workspacePath)
|
||||
workspacePath: self.workspacePath,
|
||||
paused: self.state.isPaused,
|
||||
onResume: { self.state.isPaused = false })
|
||||
}
|
||||
}
|
||||
.task {
|
||||
.task(id: "\(self.cliInstalled)-\(self.state.isPaused)") {
|
||||
guard self.state.connectionMode != .local || self.cliInstalled else { return }
|
||||
await self.onboardingWizard.startIfNeeded(
|
||||
mode: self.state.connectionMode,
|
||||
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
|
||||
@@ -34,9 +37,12 @@ private struct OnboardingWizardCardContent: View {
|
||||
@Bindable var wizard: OnboardingWizardModel
|
||||
let mode: AppState.ConnectionMode
|
||||
let workspacePath: String
|
||||
let paused: Bool
|
||||
let onResume: () -> Void
|
||||
|
||||
private enum CardState {
|
||||
case error(String)
|
||||
case paused
|
||||
case starting
|
||||
case step(WizardStep)
|
||||
case complete
|
||||
@@ -44,6 +50,7 @@ private struct OnboardingWizardCardContent: View {
|
||||
}
|
||||
|
||||
private var state: CardState {
|
||||
if self.paused, !self.wizard.isComplete { return .paused }
|
||||
if let error = wizard.errorMessage { return .error(error) }
|
||||
if self.wizard.isStarting { return .starting }
|
||||
if let step = wizard.currentStep { return .step(step) }
|
||||
@@ -69,6 +76,14 @@ private struct OnboardingWizardCardContent: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .paused:
|
||||
Text("Setup is paused")
|
||||
.font(.headline)
|
||||
Text("Resume OpenClaw to start the local Gateway and continue setup.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Resume setup", action: self.onResume)
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .starting:
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
|
||||
@@ -71,6 +71,7 @@ final class OnboardingWizardModel {
|
||||
self.errorMessage = nil
|
||||
return
|
||||
}
|
||||
guard Self.shouldStart(mode: mode, paused: AppStateStore.shared.isPaused) else { return }
|
||||
self.isStarting = true
|
||||
self.errorMessage = nil
|
||||
self.lastStartMode = mode
|
||||
@@ -100,6 +101,10 @@ final class OnboardingWizardModel {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func shouldStart(mode: AppState.ConnectionMode, paused: Bool) -> Bool {
|
||||
mode == .local && !paused
|
||||
}
|
||||
|
||||
func submit(step: WizardStep, value: AnyCodable?) async {
|
||||
guard let sessionId, !self.isSubmitting else { return }
|
||||
self.isSubmitting = true
|
||||
|
||||
@@ -31,4 +31,147 @@ struct CLIInstallerTests {
|
||||
fileManager: fm)
|
||||
#expect(missing == nil)
|
||||
}
|
||||
|
||||
@Test func `installer command runs the signed bundled script without a shell pipeline`() {
|
||||
let command = CLIInstaller.installScriptCommand(
|
||||
version: "2026.7.3-beta.1",
|
||||
prefix: "/Users/Test User/.openclaw",
|
||||
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
|
||||
|
||||
#expect(command == [
|
||||
"/bin/bash",
|
||||
"/Applications/OpenClaw.app/Contents/Resources/install-cli.sh",
|
||||
"--json",
|
||||
"--no-onboard",
|
||||
"--prefix",
|
||||
"/Users/Test User/.openclaw",
|
||||
"--version",
|
||||
"2026.7.3-beta.1",
|
||||
])
|
||||
#expect(!command.contains("curl"))
|
||||
}
|
||||
|
||||
@Test func `managed setup requires a parseable compatible version`() {
|
||||
let location = "/Users/test/.openclaw/bin/openclaw"
|
||||
|
||||
#expect(CLIInstaller.classifyVersion(
|
||||
location: location,
|
||||
output: "OpenClaw 2026.7.3\n",
|
||||
expectedVersion: "2026.7.3") == .ready(location: location, version: "2026.7.3"))
|
||||
#expect(CLIInstaller.classifyVersion(
|
||||
location: location,
|
||||
output: "OpenClaw\n",
|
||||
expectedVersion: "2026.7.3") == .unusable(location: location))
|
||||
#expect(CLIInstaller.classifyVersion(
|
||||
location: location,
|
||||
output: "2026.6.1\n",
|
||||
expectedVersion: "2026.7.3") == .incompatible(
|
||||
location: location,
|
||||
found: "2026.6.1",
|
||||
required: "2026.7.3"))
|
||||
}
|
||||
|
||||
@Test func `compatible external CLI satisfies setup`() async throws {
|
||||
let root = FileManager().temporaryDirectory.appendingPathComponent(
|
||||
"openclaw-compatible-cli-\(UUID().uuidString)")
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let executable = root.appendingPathComponent("openclaw")
|
||||
try "#!/bin/sh\necho 'OpenClaw 2026.7.3'\n".write(
|
||||
to: executable,
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let status = await CLIInstaller.status(location: executable.path)
|
||||
|
||||
#expect(status == .ready(location: executable.path, version: "2026.7.3"))
|
||||
}
|
||||
|
||||
@Test func `matching external CLI with unsupported Node is unusable`() async throws {
|
||||
let root = FileManager().temporaryDirectory.appendingPathComponent(
|
||||
"openclaw-old-node-cli-\(UUID().uuidString)")
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let executable = root.appendingPathComponent("openclaw")
|
||||
let node = root.appendingPathComponent("node")
|
||||
try "#!/bin/sh\necho 'OpenClaw 2026.7.3'\n".write(
|
||||
to: executable,
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
try "#!/bin/sh\necho 'v20.18.0'\n".write(
|
||||
to: node,
|
||||
atomically: true,
|
||||
encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: node.path)
|
||||
|
||||
let status = await CLIInstaller.status(location: executable.path)
|
||||
|
||||
#expect(status == .unusable(location: executable.path))
|
||||
}
|
||||
|
||||
@Test func `CLI probe preserves environment and resolves shebang tools beside executable`() {
|
||||
let location = "/custom/bin/openclaw"
|
||||
let environment = CLIInstaller.probeEnvironment(
|
||||
location: location,
|
||||
processEnvironment: ["HOME": "/Users/test", "PATH": "/usr/bin"],
|
||||
preferredPaths: ["/opt/homebrew/bin", "/usr/bin"])
|
||||
|
||||
#expect(environment["HOME"] == "/Users/test")
|
||||
#expect(environment["PATH"] == "/custom/bin:/opt/homebrew/bin:/usr/bin")
|
||||
}
|
||||
|
||||
@Test func `managed CLI probe prefers its private runtime`() {
|
||||
let executable = "/Users/test/.openclaw/bin/openclaw"
|
||||
let environment = CLIInstaller.probeEnvironment(
|
||||
location: executable,
|
||||
processEnvironment: [:],
|
||||
preferredPaths: ["/Users/test/.nvm/versions/node/v20/bin", "/usr/bin"],
|
||||
managedExecutable: executable,
|
||||
managedRuntimeDirectory: "/Users/test/.openclaw/tools/node/bin")
|
||||
|
||||
#expect(environment["PATH"] == [
|
||||
"/Users/test/.openclaw/bin",
|
||||
"/Users/test/.openclaw/tools/node/bin",
|
||||
"/Users/test/.nvm/versions/node/v20/bin",
|
||||
"/usr/bin",
|
||||
].joined(separator: ":"))
|
||||
}
|
||||
|
||||
@Test func `successful CLI setup starts the local gateway and waits for readiness`() async {
|
||||
var didStart = false
|
||||
var didWait = false
|
||||
|
||||
let activation = await CLIInstaller.activateLocalGateway(
|
||||
mode: .local,
|
||||
paused: false,
|
||||
start: { didStart = true },
|
||||
waitUntilReady: {
|
||||
didWait = true
|
||||
return true
|
||||
})
|
||||
|
||||
#expect(didStart)
|
||||
#expect(didWait)
|
||||
#expect(activation == .ready)
|
||||
}
|
||||
|
||||
@Test func `paused CLI setup defers gateway activation`() async {
|
||||
var didStart = false
|
||||
var didWait = false
|
||||
|
||||
let activation = await CLIInstaller.activateLocalGateway(
|
||||
mode: .local,
|
||||
paused: true,
|
||||
start: { didStart = true },
|
||||
waitUntilReady: {
|
||||
didWait = true
|
||||
return true
|
||||
})
|
||||
|
||||
#expect(!didStart)
|
||||
#expect(!didWait)
|
||||
#expect(activation == .deferred)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,65 @@ import Testing
|
||||
#expect(first == tmp.appendingPathComponent("node_modules/.bin").path)
|
||||
}
|
||||
|
||||
@Test func `managed install only precedes external installs after validation`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
let managedBin = home.appendingPathComponent(".openclaw/bin")
|
||||
try FileManager().createDirectory(at: managedBin, withIntermediateDirectories: true)
|
||||
let managedExecutable = managedBin.appendingPathComponent("openclaw")
|
||||
|
||||
let fallbackPaths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home)
|
||||
let validatedPaths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home,
|
||||
validatedExecutable: managedExecutable.path)
|
||||
|
||||
let packageManagerPath = home.appendingPathComponent("Library/pnpm").path
|
||||
let fallbackManagedIndex = try #require(fallbackPaths.firstIndex(of: managedBin.path))
|
||||
let fallbackPackageManagerIndex = try #require(fallbackPaths.firstIndex(of: packageManagerPath))
|
||||
let validatedManagedIndex = try #require(validatedPaths.firstIndex(of: managedBin.path))
|
||||
let validatedPackageManagerIndex = try #require(validatedPaths.firstIndex(of: packageManagerPath))
|
||||
#expect(fallbackManagedIndex > fallbackPackageManagerIndex)
|
||||
#expect(validatedManagedIndex < validatedPackageManagerIndex)
|
||||
}
|
||||
|
||||
@Test func `node manager runtimes precede system runtimes`() throws {
|
||||
let home = try makeTempDirForTests()
|
||||
let nodeManagerBin = home.appendingPathComponent(".nvm/versions/node/v22.19.0/bin")
|
||||
try makeExecutableForTests(at: nodeManagerBin.appendingPathComponent("node"))
|
||||
|
||||
let paths = CommandResolver.preferredPaths(
|
||||
home: home,
|
||||
current: [],
|
||||
projectRoot: home)
|
||||
|
||||
let managerIndex = try #require(paths.firstIndex(of: nodeManagerBin.path))
|
||||
let systemIndex = try #require(paths.firstIndex(of: "/opt/homebrew/bin"))
|
||||
#expect(managerIndex < systemIndex)
|
||||
}
|
||||
|
||||
@Test func `validated CLI preference expires when the app requires a newer version`() throws {
|
||||
let defaults = self.makeDefaults()
|
||||
let root = try makeTempDirForTests()
|
||||
let executable = root.appendingPathComponent("openclaw")
|
||||
FileManager().createFile(atPath: executable.path, contents: Data())
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
defaults.set(executable.path, forKey: cliValidatedExecutableKey)
|
||||
defaults.set("2026.7.3", forKey: cliValidatedVersionKey)
|
||||
|
||||
#expect(CommandResolver.validatedOpenClawExecutable(
|
||||
defaults: defaults,
|
||||
fileManager: .default,
|
||||
requiredVersion: "2026.7.3") == executable.path)
|
||||
#expect(CommandResolver.validatedOpenClawExecutable(
|
||||
defaults: defaults,
|
||||
fileManager: .default,
|
||||
requiredVersion: "2026.8.0") == nil)
|
||||
}
|
||||
|
||||
@Test func `builds SSH command for remote mode`() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
|
||||
@@ -4,6 +4,28 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacNodeModeCoordinatorTests {
|
||||
@Test @MainActor func `fresh node uses durable dedicated identity for local auto approval`() throws {
|
||||
let defaults = try #require(UserDefaults(suiteName: "MacNodeModeCoordinatorTests.fresh.\(UUID().uuidString)"))
|
||||
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: false) == .node)
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: true) == .node)
|
||||
}
|
||||
|
||||
@Test @MainActor func `upgraded node durably preserves its shipped primary identity`() throws {
|
||||
let defaults = try #require(UserDefaults(suiteName: "MacNodeModeCoordinatorTests.upgrade.\(UUID().uuidString)"))
|
||||
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: true) == .primary)
|
||||
#expect(MacNodeModeCoordinator.resolveNodeIdentityProfile(
|
||||
defaults: defaults,
|
||||
isExistingInstallation: false) == .primary)
|
||||
}
|
||||
|
||||
@Test func `remote mode does not advertise browser proxy`() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
|
||||
@@ -17,6 +17,21 @@ struct NodePairingApprovalPrompterTests {
|
||||
#expect(!options.contains("StrictHostKeyChecking=accept-new"))
|
||||
}
|
||||
|
||||
@Test func `own node is automatically approved only for a local gateway`() {
|
||||
#expect(NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: .local,
|
||||
requestNodeId: "node-1",
|
||||
localNodeId: "node-1"))
|
||||
#expect(!NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: .remote,
|
||||
requestNodeId: "node-1",
|
||||
localNodeId: "node-1"))
|
||||
#expect(!NodePairingApprovalPrompter.shouldAutoApproveOwnLocalNode(
|
||||
connectionMode: .local,
|
||||
requestNodeId: "node-2",
|
||||
localNodeId: "node-1"))
|
||||
}
|
||||
|
||||
@Test func `node pairing approval prompter exercises`() async {
|
||||
await NodePairingApprovalPrompter.exerciseForTesting()
|
||||
}
|
||||
|
||||
@@ -17,16 +17,103 @@ struct OnboardingViewSmokeTests {
|
||||
}
|
||||
|
||||
@Test func `page order omits workspace and identity steps`() {
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: false)
|
||||
#expect(!order.contains(7))
|
||||
#expect(order.contains(3))
|
||||
}
|
||||
|
||||
@Test func `page order omits onboarding chat when identity known`() {
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: false)
|
||||
#expect(!order.contains(8))
|
||||
}
|
||||
|
||||
@Test func `fresh local setup installs CLI before starting gateway wizard`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: true)
|
||||
|
||||
#expect(order.firstIndex(of: 2) == 2)
|
||||
#expect(order.firstIndex(of: 3) == 3)
|
||||
}
|
||||
|
||||
@Test func `configured local setup skips CLI install page`() {
|
||||
let order = OnboardingView.pageOrder(
|
||||
for: .local,
|
||||
showOnboardingChat: false,
|
||||
requiresCLIInstall: false)
|
||||
|
||||
#expect(!order.contains(2))
|
||||
}
|
||||
|
||||
@Test func `fresh onboarding defaults to this Mac`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = false
|
||||
state.connectionMode = .unconfigured
|
||||
let view = OnboardingView(state: state)
|
||||
|
||||
#expect(view.selectedConnectionMode == .local)
|
||||
#expect(view.isConnectionSelectionBlocking)
|
||||
#expect(state.connectionMode == .unconfigured)
|
||||
}
|
||||
|
||||
@Test func `reopened onboarding preserves configure later selection`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = true
|
||||
state.connectionMode = .unconfigured
|
||||
let view = OnboardingView(state: state)
|
||||
|
||||
#expect(view.selectedConnectionMode == .unconfigured)
|
||||
#expect(!view.isConnectionSelectionBlocking)
|
||||
#expect(state.connectionMode == .unconfigured)
|
||||
}
|
||||
|
||||
@Test func `advancing from recommended this Mac commits local mode`() {
|
||||
let state = AppState(preview: true)
|
||||
state.onboardingSeen = false
|
||||
state.connectionMode = .unconfigured
|
||||
let view = OnboardingView(state: state)
|
||||
|
||||
view.commitRecommendedConnectionIfNeeded(for: view.connectionPageIndex)
|
||||
|
||||
#expect(state.connectionMode == .local)
|
||||
}
|
||||
|
||||
@Test func `automatic CLI setup waits for the initial status probe`() {
|
||||
#expect(!OnboardingView.shouldAutoInstallCLI(
|
||||
onCLIPage: true,
|
||||
isLocal: true,
|
||||
visible: true,
|
||||
statusKnown: false,
|
||||
installed: false,
|
||||
installing: false))
|
||||
#expect(OnboardingView.shouldAutoInstallCLI(
|
||||
onCLIPage: true,
|
||||
isLocal: true,
|
||||
visible: true,
|
||||
statusKnown: true,
|
||||
installed: false,
|
||||
installing: false))
|
||||
#expect(!OnboardingView.shouldAutoInstallCLI(
|
||||
onCLIPage: true,
|
||||
isLocal: true,
|
||||
visible: false,
|
||||
statusKnown: true,
|
||||
installed: false,
|
||||
installing: false))
|
||||
}
|
||||
|
||||
@Test func `paused onboarding defers the local gateway wizard`() {
|
||||
#expect(!OnboardingWizardModel.shouldStart(mode: .local, paused: true))
|
||||
#expect(OnboardingWizardModel.shouldStart(mode: .local, paused: false))
|
||||
}
|
||||
|
||||
@Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async {
|
||||
let override = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
||||
|
||||
@@ -3,12 +3,15 @@ import Foundation
|
||||
|
||||
public enum GatewayDeviceIdentityProfile: String, Sendable {
|
||||
case primary
|
||||
case node
|
||||
case shareExtension
|
||||
|
||||
var identityFileName: String {
|
||||
switch self {
|
||||
case .primary:
|
||||
"device.json"
|
||||
case .node:
|
||||
"node-device.json"
|
||||
case .shareExtension:
|
||||
"share-device.json"
|
||||
}
|
||||
@@ -18,6 +21,8 @@ public enum GatewayDeviceIdentityProfile: String, Sendable {
|
||||
switch self {
|
||||
case .primary:
|
||||
"device-auth.json"
|
||||
case .node:
|
||||
"node-device-auth.json"
|
||||
case .shareExtension:
|
||||
"share-device-auth.json"
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ struct DeviceIdentityStoreTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `share extension profile uses separate identity and auth files`() throws {
|
||||
func `secondary profiles use separate identity and auth files`() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
@@ -65,11 +65,17 @@ struct DeviceIdentityStoreTests {
|
||||
}
|
||||
|
||||
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
|
||||
let nodeIdentity = DeviceIdentityStore.loadOrCreate(profile: .node)
|
||||
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: primaryIdentity.deviceId,
|
||||
role: "node",
|
||||
token: "primary-token")
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: nodeIdentity.deviceId,
|
||||
role: "node",
|
||||
token: "node-token",
|
||||
profile: .node)
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: shareIdentity.deviceId,
|
||||
role: "node",
|
||||
@@ -77,13 +83,21 @@ struct DeviceIdentityStoreTests {
|
||||
profile: .shareExtension)
|
||||
|
||||
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
|
||||
#expect(primaryIdentity.deviceId != nodeIdentity.deviceId)
|
||||
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("node-device.json").path))
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
|
||||
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
|
||||
#expect(FileManager.default
|
||||
.fileExists(atPath: identityDir.appendingPathComponent("node-device-auth.json").path))
|
||||
#expect(FileManager.default
|
||||
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: nodeIdentity.deviceId,
|
||||
role: "node",
|
||||
profile: .node)?.token == "node-token")
|
||||
#expect(
|
||||
DeviceAuthStore
|
||||
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
|
||||
@@ -92,6 +106,10 @@ struct DeviceIdentityStoreTests {
|
||||
DeviceAuthStore.clearAll(profile: .shareExtension)
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
|
||||
#expect(DeviceAuthStore.loadToken(
|
||||
deviceId: nodeIdentity.deviceId,
|
||||
role: "node",
|
||||
profile: .node)?.token == "node-token")
|
||||
#expect(DeviceAuthStore
|
||||
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
|
||||
}
|
||||
|
||||
+3
-2
@@ -4964,7 +4964,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
|
||||
- Route: /platforms/mac/bundled-gateway
|
||||
- Headings:
|
||||
- H2: Install the CLI (required for local mode)
|
||||
- H2: Automatic setup
|
||||
- H2: Manual recovery
|
||||
- H2: Launchd (Gateway as LaunchAgent)
|
||||
- H2: Version compatibility
|
||||
- H2: State directory on macOS
|
||||
@@ -5004,7 +5005,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Prerequisites
|
||||
- H2: 1. Install Dependencies
|
||||
- H2: 2. Build and Package the App
|
||||
- H2: 3. Install the CLI
|
||||
- H2: 3. Install the CLI and Gateway
|
||||
- H2: Troubleshooting
|
||||
- H3: Build fails: toolchain or SDK mismatch
|
||||
- H3: App crashes on permission grant
|
||||
|
||||
@@ -12,17 +12,30 @@ expects an **external** `openclaw` CLI install, does not spawn the Gateway as a
|
||||
child process, and manages a per-user launchd service to keep the Gateway
|
||||
running (or attaches to an existing local Gateway if one is already running).
|
||||
|
||||
## Install the CLI (required for local mode)
|
||||
## Automatic setup
|
||||
|
||||
Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.19+`, still works for compatibility. Then install `openclaw` globally:
|
||||
On a fresh Mac, choose **This Mac** during onboarding. The app runs its signed,
|
||||
bundled installer before the Gateway wizard, installs a user-space Node runtime
|
||||
and the matching `openclaw` CLI under `~/.openclaw`, then installs and starts the
|
||||
per-user launchd service. This path does not require Terminal, Homebrew, or
|
||||
administrator access.
|
||||
|
||||
The app bundles the installer script, not the Node or Gateway payload. Setup
|
||||
therefore needs an internet connection to download the runtime and matching
|
||||
OpenClaw package.
|
||||
|
||||
## Manual recovery
|
||||
|
||||
Node 24 is recommended for a manual install. Node 22 LTS, currently `22.19+`,
|
||||
also works. Then install `openclaw` globally:
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@<version>
|
||||
```
|
||||
|
||||
The macOS app's **Install CLI** button runs the same global install flow the app
|
||||
uses internally: it prefers npm first, then pnpm, then bun if that is the only
|
||||
detected package manager. Node remains the recommended Gateway runtime.
|
||||
Use **Retry setup** after a failed automatic setup. If that still fails, install
|
||||
the CLI manually with the command above, then choose **Check again** in
|
||||
onboarding. Node remains the recommended Gateway runtime.
|
||||
|
||||
## Launchd (Gateway as LaunchAgent)
|
||||
|
||||
@@ -54,8 +67,10 @@ Logging:
|
||||
|
||||
## Version compatibility
|
||||
|
||||
The macOS app checks the gateway version against its own version. If they're
|
||||
incompatible, update the global CLI to match the app version.
|
||||
The macOS app checks the Gateway version against its own version. Onboarding
|
||||
automatically runs managed setup when an existing CLI is missing or
|
||||
incompatible. Use **Retry setup** to repeat the installation or **Check again**
|
||||
after repairing an external CLI.
|
||||
|
||||
## State directory on macOS
|
||||
|
||||
|
||||
@@ -39,17 +39,13 @@ For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app
|
||||
|
||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||
|
||||
## 3. Install the CLI
|
||||
## 3. Install the CLI and Gateway
|
||||
|
||||
The macOS app expects a global `openclaw` CLI install to manage background tasks.
|
||||
The packaged app embeds the canonical `scripts/install-cli.sh` installer. On a
|
||||
fresh profile, choose **This Mac** during onboarding; the app installs the
|
||||
matching user-space CLI and runtime before starting the Gateway wizard.
|
||||
|
||||
**To install it (recommended):**
|
||||
|
||||
1. Open the OpenClaw app.
|
||||
2. Go to the **General** settings tab.
|
||||
3. Click **"Install CLI"**.
|
||||
|
||||
Alternatively, install it manually:
|
||||
For manual development recovery, install the matching CLI yourself:
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@<version>
|
||||
|
||||
@@ -29,10 +29,10 @@ app from source with [macOS dev setup](/platforms/mac/dev-setup).
|
||||
## First run
|
||||
|
||||
1. Install and launch **OpenClaw.app**.
|
||||
2. Complete the macOS permission checklist.
|
||||
3. Pick **Local** or **Remote** mode.
|
||||
4. Install the `openclaw` CLI if the app asks for it.
|
||||
5. Open WebChat from the menu bar and send a test message.
|
||||
2. Pick **This Mac** for a local Gateway, or connect to a remote Gateway.
|
||||
3. For local mode, wait while the app installs its user-space runtime and Gateway.
|
||||
4. Complete provider setup and the macOS permission checklist.
|
||||
5. Send the onboarding test message.
|
||||
|
||||
For the CLI/Gateway setup path, use [Getting started](/start/getting-started).
|
||||
For permission recovery, use [macOS permissions](/platforms/mac/permissions).
|
||||
@@ -44,8 +44,9 @@ For permission recovery, use [macOS permissions](/platforms/mac/permissions).
|
||||
| Local | This Mac should run the Gateway and keep it alive with launchd. | [Gateway on macOS](/platforms/mac/bundled-gateway) |
|
||||
| Remote | Another host runs the Gateway and this Mac should control it over SSH, LAN, or Tailnet. | [Remote control](/platforms/mac/remote) |
|
||||
|
||||
Local mode requires an installed `openclaw` CLI. The app can install it, or you
|
||||
can follow [Gateway on macOS](/platforms/mac/bundled-gateway).
|
||||
Local mode requires an installed `openclaw` CLI. On a fresh Mac, the app installs
|
||||
the matching CLI and runtime automatically before starting the Gateway wizard.
|
||||
See [Gateway on macOS](/platforms/mac/bundled-gateway) for manual recovery.
|
||||
|
||||
## What the app owns
|
||||
|
||||
|
||||
@@ -1183,8 +1183,6 @@ main() {
|
||||
RUN_ONBOARD=0
|
||||
fi
|
||||
|
||||
cleanup_legacy_submodules
|
||||
|
||||
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
|
||||
export PATH
|
||||
|
||||
|
||||
@@ -285,6 +285,15 @@ echo "📦 Copying device model resources"
|
||||
rm -rf "$APP_ROOT/Contents/Resources/DeviceModels"
|
||||
cp -R "$ROOT_DIR/apps/macos/Sources/OpenClaw/Resources/DeviceModels" "$APP_ROOT/Contents/Resources/DeviceModels"
|
||||
|
||||
echo "📦 Copying CLI installer"
|
||||
INSTALL_CLI_SRC="$ROOT_DIR/scripts/install-cli.sh"
|
||||
if [ ! -f "$INSTALL_CLI_SRC" ]; then
|
||||
echo "ERROR: CLI installer missing at $INSTALL_CLI_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$INSTALL_CLI_SRC" "$APP_ROOT/Contents/Resources/install-cli.sh"
|
||||
chmod 0644 "$APP_ROOT/Contents/Resources/install-cli.sh"
|
||||
|
||||
echo "🌐 Copying app localizations"
|
||||
node --import tsx "$ROOT_DIR/scripts/apple-app-i18n.ts" compile-macos \
|
||||
--output "$APP_ROOT/Contents/Resources"
|
||||
|
||||
@@ -41,6 +41,12 @@ function linkRequiredShellTools(bin: string) {
|
||||
describe("install-cli.sh", () => {
|
||||
const script = readFileSync(SCRIPT_PATH, "utf8");
|
||||
|
||||
it("does not clean an unrelated legacy checkout during the default npm install", () => {
|
||||
const main = script.slice(script.indexOf("\nmain() {"));
|
||||
expect(main).not.toContain("cleanup_legacy_submodules");
|
||||
expect(script).toContain('cleanup_legacy_submodules "$repo_dir"');
|
||||
});
|
||||
|
||||
it("rejects installer options with missing values", () => {
|
||||
const result = runInstallCliShell(`
|
||||
set -euo pipefail
|
||||
@@ -166,10 +172,7 @@ describe("install-cli.sh", () => {
|
||||
mkdirSync(bin, { recursive: true });
|
||||
mkdirSync(outer, { recursive: true });
|
||||
mkdirSync(repo, { recursive: true });
|
||||
writeFileSync(
|
||||
join(outer, "package.json"),
|
||||
'{\n "packageManager": "yarn@4.5.0"\n}\n',
|
||||
);
|
||||
writeFileSync(join(outer, "package.json"), '{\n "packageManager": "yarn@4.5.0"\n}\n');
|
||||
writeFileSync(
|
||||
join(repo, "package.json"),
|
||||
'{\n "packageManager": "pnpm@11.2.2+sha512.test"\n}\n',
|
||||
|
||||
@@ -402,6 +402,17 @@ describe("package-mac-app plist stamping", () => {
|
||||
expect(script).not.toContain("ALLOW_MISSING_TEXTUAL_BUNDLE");
|
||||
});
|
||||
|
||||
it("embeds the canonical CLI installer as a signed app resource", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
|
||||
expect(script).toContain('INSTALL_CLI_SRC="$ROOT_DIR/scripts/install-cli.sh"');
|
||||
expect(script).toContain('cp "$INSTALL_CLI_SRC" "$APP_ROOT/Contents/Resources/install-cli.sh"');
|
||||
expect(script).toContain('chmod 0644 "$APP_ROOT/Contents/Resources/install-cli.sh"');
|
||||
expect(script.indexOf("Copying CLI installer")).toBeLessThan(
|
||||
script.indexOf('echo "🔏 Signing bundle'),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mask required Info.plist stamp failures", () => {
|
||||
const script = readFileSync(scriptPath, "utf8");
|
||||
const stampBlock = script.slice(
|
||||
|
||||
Reference in New Issue
Block a user