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:
Peter Steinberger
2026-07-03 22:09:56 -07:00
committed by GitHub
parent 8822b66952
commit 6a7b62889e
31 changed files with 1299 additions and 365 deletions
+256 -152
View File
@@ -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": "Dont 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 cant 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 assistants behavior. For backup, make the workspace a private git repo so your agents “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 youre 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": "Couldnt 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() {
+214 -21
View File
@@ -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"
+17 -2
View File
@@ -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()
}
}
+35 -10
View File
@@ -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: "Dont 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
View File
@@ -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
+22 -7
View File
@@ -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
+5 -9
View File
@@ -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>
+7 -6
View File
@@ -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
-2
View File
@@ -1183,8 +1183,6 @@ main() {
RUN_ONBOARD=0
fi
cleanup_legacy_submodules
PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}"
export PATH
+9
View File
@@ -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"
+7 -4
View File
@@ -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',
+11
View File
@@ -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(