diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index ebfe825f51ad..8781056cc599 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -12753,6 +12753,38 @@ "surface": "apple", "id": "native.apple.69b64bb81b900cc4" }, + { + "kind": "conditional-branch", + "line": 34, + "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", + "source": "OpenClaw Gateway \\(version) is ready.", + "surface": "apple", + "id": "native.apple.cfc2509b8bbcf1d7" + }, + { + "kind": "conditional-branch", + "line": 36, + "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", + "source": "OpenClaw Gateway is not installed yet.", + "surface": "apple", + "id": "native.apple.9b767e46895714f9" + }, + { + "kind": "conditional-branch", + "line": 38, + "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", + "source": "The OpenClaw Gateway could not be verified. Setup will repair it.", + "surface": "apple", + "id": "native.apple.b4dc58a1b294e140" + }, + { + "kind": "conditional-branch", + "line": 40, + "path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift", + "source": "Gateway \\(found) does not match app \\(required). Setup will update it.", + "surface": "apple", + "id": "native.apple.4088056ae7b0f3b4" + }, { "kind": "conditional-branch", "line": 168, @@ -16131,7 +16163,7 @@ }, { "kind": "conditional-branch", - "line": 351, + "line": 352, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Main", "surface": "apple", @@ -16139,7 +16171,7 @@ }, { "kind": "conditional-branch", - "line": 351, + "line": 352, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Other", "surface": "apple", @@ -16147,7 +16179,7 @@ }, { "kind": "ui-call", - "line": 439, + "line": 440, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Refreshing microphones…", "surface": "apple", @@ -16155,7 +16187,7 @@ }, { "kind": "ui-call", - "line": 446, + "line": 447, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Microphone", "surface": "apple", @@ -16163,7 +16195,7 @@ }, { "kind": "ui-call", - "line": 467, + "line": 468, "path": "apps/macos/Sources/OpenClaw/MenuContentView.swift", "source": "Disconnected (using System default)", "surface": "apple", @@ -16331,7 +16363,7 @@ }, { "kind": "conditional-branch", - "line": 140, + "line": 164, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Finish", "surface": "apple", @@ -16339,7 +16371,7 @@ }, { "kind": "conditional-branch", - "line": 140, + "line": 164, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Next", "surface": "apple", @@ -16347,7 +16379,7 @@ }, { "kind": "ui-call", - "line": 99, + "line": 107, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift", "source": "Back", "surface": "apple", @@ -16362,48 +16394,88 @@ "id": "native.apple.ea8beaeecef15e54" }, { - "kind": "ui-call", + "kind": "ui-call-concatenated", "line": 36, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.", + "source": "OpenClaw is your personal AI assistant. It can answer questions, work with files and apps, and connect to services like WhatsApp and Telegram through a Gateway you control.", "surface": "apple", - "id": "native.apple.69f81c58c14a5df4" - }, - { - "kind": "ui-call", - "line": 53, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Security notice", - "surface": "apple", - "id": "native.apple.e75671ec2ae4bf9d" - }, - { - "kind": "ui-call-concatenated", - "line": 55, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, including running commands, reading/writing files, and capturing screenshots — depending on the permissions you grant.\n\nOnly enable OpenClaw if you understand the risks and trust the prompts and integrations you use.", - "surface": "apple", - "id": "native.apple.c4778e8647a5aa9f" - }, - { - "kind": "ui-call", - "line": 75, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Choose your Gateway", - "surface": "apple", - "id": "native.apple.9fb535bde270a73e" - }, - { - "kind": "ui-call-concatenated", - "line": 77, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "OpenClaw uses a single Gateway that stays running. Pick this Mac, connect to a discovered gateway nearby, or configure later.", - "surface": "apple", - "id": "native.apple.16dfe3ecc39e7bd5" + "id": "native.apple.dd7411f18b0fe203" }, { "kind": "ui-named-argument", - "line": 90, + "line": 47, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Ask, create, and automate", + "surface": "apple", + "id": "native.apple.86e4509eb0626ec8" + }, + { + "kind": "ui-named-argument", + "line": 48, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Give your assistant tasks and let it help across your Mac.", + "surface": "apple", + "id": "native.apple.a6950fed99ae450d" + }, + { + "kind": "ui-named-argument", + "line": 51, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Connect the tools you use", + "surface": "apple", + "id": "native.apple.16ab10ed9293b718" + }, + { + "kind": "ui-named-argument", + "line": 52, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Bring conversations and services together in one assistant.", + "surface": "apple", + "id": "native.apple.687c1c2abdb8581e" + }, + { + "kind": "ui-named-argument", + "line": 55, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Stay in control", + "surface": "apple", + "id": "native.apple.caf822c2d393914f" + }, + { + "kind": "ui-named-argument", + "line": 56, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Choose where the Gateway runs and which permissions OpenClaw receives.", + "surface": "apple", + "id": "native.apple.203bfa5e80135faa" + }, + { + "kind": "ui-call-concatenated", + "line": 62, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "OpenClaw can take actions using the permissions and services you enable. Review prompts and only connect tools you trust.", + "surface": "apple", + "id": "native.apple.fb64a5b187cfdc15" + }, + { + "kind": "ui-call", + "line": 78, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Where should OpenClaw run?", + "surface": "apple", + "id": "native.apple.2033d33228ce52d7" + }, + { + "kind": "ui-call-concatenated", + "line": 80, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "For the simplest setup, run the Gateway on this Mac. OpenClaw installs it and keeps it running for you.", + "surface": "apple", + "id": "native.apple.4791cce2ae762f19" + }, + { + "kind": "ui-named-argument", + "line": 93, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "This Mac", "surface": "apple", @@ -16411,7 +16483,7 @@ }, { "kind": "ui-named-argument", - "line": 108, + "line": 111, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Don’t start the Gateway yet.", "surface": "apple", @@ -16419,7 +16491,7 @@ }, { "kind": "conditional-branch", - "line": 141, + "line": 144, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Existing gateway detected", "surface": "apple", @@ -16427,7 +16499,7 @@ }, { "kind": "conditional-branch", - "line": 141, + "line": 144, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Port \\(probe.port) already in use", "surface": "apple", @@ -16435,7 +16507,7 @@ }, { "kind": "conditional-branch", - "line": 143, + "line": 146, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": " (\\(probe.command) pid \\(probe.pid))", "surface": "apple", @@ -16443,7 +16515,7 @@ }, { "kind": "ui-modifier", - "line": 162, + "line": 165, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Retry remote discovery (Tailscale DNS-SD + Serve probe).", "surface": "apple", @@ -16451,7 +16523,7 @@ }, { "kind": "ui-call", - "line": 168, + "line": 171, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Searching for nearby gateways…", "surface": "apple", @@ -16459,7 +16531,7 @@ }, { "kind": "ui-call", - "line": 174, + "line": 177, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Nearby gateways", "surface": "apple", @@ -16467,7 +16539,7 @@ }, { "kind": "conditional-branch", - "line": 197, + "line": 200, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Advanced…", "surface": "apple", @@ -16475,7 +16547,7 @@ }, { "kind": "conditional-branch", - "line": 197, + "line": 200, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Hide Advanced", "surface": "apple", @@ -16483,7 +16555,7 @@ }, { "kind": "ui-call", - "line": 217, + "line": 220, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Transport", "surface": "apple", @@ -16491,7 +16563,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 221, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "SSH tunnel", "surface": "apple", @@ -16499,7 +16571,7 @@ }, { "kind": "ui-call", - "line": 219, + "line": 222, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Direct (ws/wss)", "surface": "apple", @@ -16507,7 +16579,7 @@ }, { "kind": "ui-call", - "line": 226, + "line": 229, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway URL", "surface": "apple", @@ -16515,7 +16587,7 @@ }, { "kind": "ui-call", - "line": 229, + "line": 232, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "wss://gateway.example.ts.net", "surface": "apple", @@ -16523,7 +16595,7 @@ }, { "kind": "ui-call", - "line": 236, + "line": 239, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "SSH target", "surface": "apple", @@ -16531,7 +16603,7 @@ }, { "kind": "ui-call", - "line": 256, + "line": 259, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Identity file", "surface": "apple", @@ -16539,7 +16611,7 @@ }, { "kind": "ui-call", - "line": 259, + "line": 262, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/Users/you/.ssh/id_ed25519", "surface": "apple", @@ -16547,7 +16619,7 @@ }, { "kind": "ui-call", - "line": 264, + "line": 267, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Project root", "surface": "apple", @@ -16555,7 +16627,7 @@ }, { "kind": "ui-call", - "line": 267, + "line": 270, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/home/you/Projects/openclaw", "surface": "apple", @@ -16563,7 +16635,7 @@ }, { "kind": "ui-call", - "line": 272, + "line": 275, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "CLI path", "surface": "apple", @@ -16571,7 +16643,7 @@ }, { "kind": "ui-call", - "line": 275, + "line": 278, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/Applications/OpenClaw.app/.../openclaw", "surface": "apple", @@ -16579,7 +16651,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 288, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: keep Tailscale enabled so your gateway stays reachable.", "surface": "apple", @@ -16587,7 +16659,7 @@ }, { "kind": "conditional-branch", - "line": 285, + "line": 288, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.", "surface": "apple", @@ -16595,7 +16667,7 @@ }, { "kind": "ui-call", - "line": 344, + "line": 347, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote connection", "surface": "apple", @@ -16603,7 +16675,7 @@ }, { "kind": "ui-call", - "line": 346, + "line": 349, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Checks the real remote websocket and auth handshake.", "surface": "apple", @@ -16611,7 +16683,7 @@ }, { "kind": "ui-call", - "line": 359, + "line": 362, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Check connection", "surface": "apple", @@ -16619,7 +16691,7 @@ }, { "kind": "ui-call", - "line": 389, + "line": 392, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway token", "surface": "apple", @@ -16627,7 +16699,7 @@ }, { "kind": "ui-call", - "line": 392, + "line": 395, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "remote gateway auth token (gateway.remote.token)", "surface": "apple", @@ -16635,7 +16707,7 @@ }, { "kind": "ui-call", - "line": 396, + "line": 399, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Used when the remote gateway requires token auth.", "surface": "apple", @@ -16643,7 +16715,7 @@ }, { "kind": "ui-call-concatenated", - "line": 400, + "line": 403, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.", "surface": "apple", @@ -16651,7 +16723,7 @@ }, { "kind": "ui-call", - "line": 417, + "line": 420, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Checking remote gateway…", "surface": "apple", @@ -16659,7 +16731,7 @@ }, { "kind": "conditional-branch", - "line": 547, + "line": 550, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": " · ssh \\(parsed.port)", "surface": "apple", @@ -16667,7 +16739,7 @@ }, { "kind": "ui-call", - "line": 594, + "line": 597, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Grant permissions", "surface": "apple", @@ -16675,7 +16747,7 @@ }, { "kind": "ui-call", - "line": 596, + "line": 599, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "These macOS permissions let OpenClaw automate apps and capture context on this Mac.", "surface": "apple", @@ -16683,7 +16755,7 @@ }, { "kind": "ui-modifier", - "line": 622, + "line": 625, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh status", "surface": "apple", @@ -16691,63 +16763,71 @@ }, { "kind": "ui-call", - "line": 635, + "line": 638, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Install the CLI", + "source": "Setting up OpenClaw", "surface": "apple", - "id": "native.apple.507f071d2b76be9e" + "id": "native.apple.8d7590f1d8f61a1d" }, { "kind": "ui-call", - "line": 637, + "line": 640, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Required for local mode: installs `openclaw` so launchd can run the gateway.", + "source": "OpenClaw is installing the local Gateway and its managed runtime.", "surface": "apple", - "id": "native.apple.5ced9b0fad1555e0" + "id": "native.apple.e88512a0fe7fb140" }, { - "kind": "conditional-branch", - "line": 649, + "kind": "ui-call", + "line": 652, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Install CLI", + "source": "Checking existing setup…", "surface": "apple", - "id": "native.apple.fa55cb789ef49397" + "id": "native.apple.0c05ab4b362710de" }, { - "kind": "conditional-branch", - "line": 649, + "kind": "ui-call", + "line": 659, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Reinstall CLI", + "source": "Installing the Gateway…", "surface": "apple", - "id": "native.apple.03ab2f6a2e08734d" + "id": "native.apple.56b68eb592ecf449" }, { - "kind": "conditional-branch", + "kind": "ui-call", "line": 663, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Copy install command", + "source": "Gateway installed", "surface": "apple", - "id": "native.apple.d977c700e4ece7fb" + "id": "native.apple.80155398d97fbe63" }, { "kind": "ui-call", - "line": 669, + "line": 672, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Installed at \\(loc)", + "source": "Retry setup", "surface": "apple", - "id": "native.apple.e6a9668517354178" - }, - { - "kind": "ui-call-multiline", - "line": 680, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Installs a user-space Node 22.19+ runtime and the CLI (no Homebrew).\nRerun anytime to reinstall or update.", - "surface": "apple", - "id": "native.apple.ac9f9e9da6ae6605" + "id": "native.apple.2d2acbf2e16f8604" }, { "kind": "ui-call", - "line": 694, + "line": 674, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Check again", + "surface": "apple", + "id": "native.apple.4d58294df8d0b1e6" + }, + { + "kind": "ui-call", + "line": 687, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", + "source": "Uses a private user-space install. No Terminal, administrator access, or Homebrew required.", + "surface": "apple", + "id": "native.apple.15cfa96c75b57b04" + }, + { + "kind": "ui-call", + "line": 696, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Agent workspace", "surface": "apple", @@ -16755,7 +16835,7 @@ }, { "kind": "ui-call-concatenated", - "line": 696, + "line": 698, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OpenClaw runs the agent from a dedicated workspace so it can load `AGENTS.md` and write files there without mixing into your other projects.", "surface": "apple", @@ -16763,7 +16843,7 @@ }, { "kind": "ui-call", - "line": 707, + "line": 709, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway detected", "surface": "apple", @@ -16771,7 +16851,7 @@ }, { "kind": "ui-call-concatenated", - "line": 709, + "line": 711, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Create the workspace on the remote host (SSH in first). The macOS app can’t write files on your gateway over SSH yet.", "surface": "apple", @@ -16779,7 +16859,7 @@ }, { "kind": "conditional-branch", - "line": 715, + "line": 717, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Copied", "surface": "apple", @@ -16787,7 +16867,7 @@ }, { "kind": "conditional-branch", - "line": 715, + "line": 717, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Copy setup command", "surface": "apple", @@ -16795,7 +16875,7 @@ }, { "kind": "ui-call", - "line": 721, + "line": 723, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Workspace folder", "surface": "apple", @@ -16803,7 +16883,7 @@ }, { "kind": "ui-call", - "line": 735, + "line": 737, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Create workspace", "surface": "apple", @@ -16811,7 +16891,7 @@ }, { "kind": "ui-call", - "line": 741, + "line": 743, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open folder", "surface": "apple", @@ -16819,7 +16899,7 @@ }, { "kind": "ui-call", - "line": 748, + "line": 750, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Save in config", "surface": "apple", @@ -16827,7 +16907,7 @@ }, { "kind": "ui-call-concatenated", - "line": 769, + "line": 771, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. For backup, make the workspace a private git repo so your agent’s “memory” is versioned.", "surface": "apple", @@ -16835,7 +16915,7 @@ }, { "kind": "ui-call", - "line": 784, + "line": 786, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Meet your agent", "surface": "apple", @@ -16843,7 +16923,7 @@ }, { "kind": "ui-call-concatenated", - "line": 786, + "line": 788, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "This is a dedicated onboarding chat. Your agent will introduce itself, learn who you are, and help you connect WhatsApp or Telegram if you want.", "surface": "apple", @@ -16851,7 +16931,7 @@ }, { "kind": "ui-call", - "line": 807, + "line": 809, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "All set", "surface": "apple", @@ -16859,7 +16939,7 @@ }, { "kind": "ui-named-argument", - "line": 812, + "line": 814, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Configure later", "surface": "apple", @@ -16867,7 +16947,7 @@ }, { "kind": "ui-named-argument", - "line": 813, + "line": 815, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Pick Local or Remote in Settings → General whenever you’re ready.", "surface": "apple", @@ -16875,7 +16955,7 @@ }, { "kind": "ui-named-argument", - "line": 820, + "line": 822, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway checklist", "surface": "apple", @@ -16883,7 +16963,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 821, + "line": 823, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.", "surface": "apple", @@ -16891,7 +16971,7 @@ }, { "kind": "ui-named-argument", - "line": 830, + "line": 832, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel", "surface": "apple", @@ -16899,7 +16979,7 @@ }, { "kind": "ui-named-argument", - "line": 831, + "line": 833, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Click the OpenClaw menu bar icon for quick chat and status.", "surface": "apple", @@ -16907,7 +16987,7 @@ }, { "kind": "ui-named-argument", - "line": 834, + "line": 836, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Connect WhatsApp or Telegram", "surface": "apple", @@ -16915,7 +16995,7 @@ }, { "kind": "ui-named-argument", - "line": 835, + "line": 837, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels to link channels and monitor status.", "surface": "apple", @@ -16923,7 +17003,7 @@ }, { "kind": "ui-named-argument", - "line": 837, + "line": 839, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels", "surface": "apple", @@ -16931,7 +17011,7 @@ }, { "kind": "ui-named-argument", - "line": 842, + "line": 844, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try Voice Wake", "surface": "apple", @@ -16939,7 +17019,7 @@ }, { "kind": "ui-named-argument", - "line": 843, + "line": 845, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", "surface": "apple", @@ -16947,7 +17027,7 @@ }, { "kind": "ui-named-argument", - "line": 846, + "line": 848, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Use the panel + Canvas", "surface": "apple", @@ -16955,7 +17035,7 @@ }, { "kind": "ui-named-argument", - "line": 847, + "line": 849, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel for quick chat; the agent can show previews ", "surface": "apple", @@ -16963,7 +17043,7 @@ }, { "kind": "ui-named-argument", - "line": 851, + "line": 853, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Give your agent more powers", "surface": "apple", @@ -16971,7 +17051,7 @@ }, { "kind": "ui-named-argument", - "line": 852, + "line": 854, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", "surface": "apple", @@ -16979,7 +17059,7 @@ }, { "kind": "ui-named-argument", - "line": 854, + "line": 856, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Skills", "surface": "apple", @@ -16987,7 +17067,7 @@ }, { "kind": "ui-call", - "line": 859, + "line": 861, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Launch at login", "surface": "apple", @@ -16995,7 +17075,7 @@ }, { "kind": "ui-call", - "line": 880, + "line": 882, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skills included", "surface": "apple", @@ -17003,7 +17083,7 @@ }, { "kind": "ui-call", - "line": 887, + "line": 889, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh", "surface": "apple", @@ -17011,7 +17091,7 @@ }, { "kind": "ui-call", - "line": 896, + "line": 898, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Couldn’t load skills from the Gateway.", "surface": "apple", @@ -17019,7 +17099,7 @@ }, { "kind": "ui-call-concatenated", - "line": 899, + "line": 901, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).", "surface": "apple", @@ -17027,7 +17107,7 @@ }, { "kind": "ui-call", - "line": 905, + "line": 907, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Details: \\(error)", "surface": "apple", @@ -17035,7 +17115,7 @@ }, { "kind": "ui-call", - "line": 911, + "line": 913, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No skills reported yet.", "surface": "apple", @@ -17059,7 +17139,7 @@ }, { "kind": "ui-call", - "line": 57, + "line": 64, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", "source": "Wizard error", "surface": "apple", @@ -17067,7 +17147,7 @@ }, { "kind": "ui-call", - "line": 63, + "line": 70, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", "source": "Retry", "surface": "apple", @@ -17075,7 +17155,31 @@ }, { "kind": "ui-call", - "line": 75, + "line": 80, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", + "source": "Setup is paused", + "surface": "apple", + "id": "native.apple.4850230e903ad303" + }, + { + "kind": "ui-call", + "line": 82, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", + "source": "Resume OpenClaw to start the local Gateway and continue setup.", + "surface": "apple", + "id": "native.apple.860797a4ad8455d2" + }, + { + "kind": "ui-call", + "line": 85, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", + "source": "Resume setup", + "surface": "apple", + "id": "native.apple.9781249acc361d32" + }, + { + "kind": "ui-call", + "line": 90, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", "source": "Starting wizard…", "surface": "apple", @@ -17083,7 +17187,7 @@ }, { "kind": "ui-call", - "line": 87, + "line": 102, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", "source": "Wizard complete. Continue to the next step.", "surface": "apple", @@ -17091,7 +17195,7 @@ }, { "kind": "ui-call", - "line": 90, + "line": 105, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", "source": "Waiting for wizard…", "surface": "apple", @@ -17099,7 +17203,7 @@ }, { "kind": "ui-call", - "line": 281, + "line": 286, "path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift", "source": "Unsupported step type", "surface": "apple", @@ -17107,7 +17211,7 @@ }, { "kind": "conditional-branch", - "line": 286, + "line": 291, "path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift", "source": "Continue", "surface": "apple", @@ -17115,7 +17219,7 @@ }, { "kind": "conditional-branch", - "line": 286, + "line": 291, "path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift", "source": "Run", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index f6d83819403f..143cba9829dc 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -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() { diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index ce6d25202ae2..82ab15b3ba4c 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -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() + 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 { diff --git a/apps/macos/Sources/OpenClaw/CommandResolver.swift b/apps/macos/Sources/OpenClaw/CommandResolver.swift index 05f7094eebea..7d79fa9d5990 100644 --- a/apps/macos/Sources/OpenClaw/CommandResolver.swift +++ b/apps/macos/Sources/OpenClaw/CommandResolver.swift @@ -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() + 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] { diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 345c225bac47..01dcc0cc1300 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -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" diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index d672347f8914..bd507defea0d 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -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 } diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift index b4d4fa02721f..3e9104b1eaf3 100644 --- a/apps/macos/Sources/OpenClaw/MenuContentView.swift +++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift @@ -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) diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 106d08a43ce4..e78d656179d5 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -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? @@ -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) diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift index 49bf1711002e..126fe989c26e 100644 --- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift @@ -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() } } diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index b7d3996c0879..4dd0562236f2 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index 2f1c5ae7846d..2183c89da8c2 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -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() diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index 7ea549d9abb0..dde1a45d4516 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -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.. (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( diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index e7150edc55b8..6ac36000e2fc 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index cf0e6d9a8919..41bd80b77871 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -13,12 +13,12 @@ extension OnboardingView { self.welcomePage() case 1: self.connectionPage() + case 2: + self.cliPage() case 3: self.wizardPage() case 5: self.permissionsPage() - case 6: - self.cliPage() case 8: self.onboardingChatPage() case 9: @@ -30,53 +30,56 @@ extension OnboardingView { func welcomePage() -> some View { self.onboardingPage { - VStack(spacing: 22) { + VStack(spacing: 18) { Text("Welcome to OpenClaw") .font(.largeTitle.weight(.semibold)) - Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.") + Text( + "OpenClaw is your personal AI assistant. It can answer questions, work with files and apps, " + + "and connect to services like WhatsApp and Telegram through a Gateway you control.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .lineLimit(2) .frame(maxWidth: 560) .fixedSize(horizontal: false, vertical: true) - self.onboardingCard(spacing: 10, padding: 14) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.title3.weight(.semibold)) - .foregroundStyle(Color(nsColor: .systemOrange)) - .frame(width: 22) - .padding(.top, 1) - - VStack(alignment: .leading, spacing: 6) { - Text("Security notice") - .font(.headline) - Text( - "The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " + - "including running commands, reading/writing files, and capturing screenshots — " + - "depending on the permissions you grant.\n\n" + - "Only enable OpenClaw if you understand the risks and trust the prompts and " + - "integrations you use.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } + self.onboardingCard(spacing: 14, padding: 16) { + self.featureRow( + title: "Ask, create, and automate", + subtitle: "Give your assistant tasks and let it help across your Mac.", + systemImage: "sparkles") + self.featureRow( + title: "Connect the tools you use", + subtitle: "Bring conversations and services together in one assistant.", + systemImage: "point.3.connected.trianglepath.dotted") + self.featureRow( + title: "Stay in control", + subtitle: "Choose where the Gateway runs and which permissions OpenClaw receives.", + systemImage: "hand.raised.fill") } .frame(maxWidth: 520) + + Label { + Text( + "OpenClaw can take actions using the permissions and services you enable. " + + "Review prompts and only connect tools you trust.") + } icon: { + Image(systemName: "info.circle") + } + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: 500, alignment: .leading) } - .padding(.top, 16) + .padding(.top, 8) } } func connectionPage() -> some View { self.onboardingPage { - Text("Choose your Gateway") + Text("Where should OpenClaw run?") .font(.largeTitle.weight(.semibold)) Text( - "OpenClaw uses a single Gateway that stays running. Pick this Mac, " + - "connect to a discovered gateway nearby, or configure later.") + "For the simplest setup, run the Gateway on this Mac. " + + "OpenClaw installs it and keeps it running for you.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -89,7 +92,7 @@ extension OnboardingView { self.connectionChoiceButton( title: "This Mac", subtitle: self.localGatewaySubtitle, - selected: self.state.connectionMode == .local) + selected: self.selectedConnectionMode == .local) { self.selectLocalGateway() } @@ -106,7 +109,7 @@ extension OnboardingView { self.connectionChoiceButton( title: "Configure later", subtitle: "Don’t start the Gateway yet.", - selected: self.state.connectionMode == .unconfigured) + selected: self.selectedConnectionMode == .unconfigured) { self.selectUnconfiguredGateway() } @@ -135,7 +138,7 @@ extension OnboardingView { private var localGatewaySubtitle: String { guard let probe = self.localGatewayProbe else { - return "Gateway starts automatically on this Mac." + return "Recommended — installs and starts automatically." } let base = probe.expected ? "Existing gateway detected" @@ -632,9 +635,9 @@ extension OnboardingView { func cliPage() -> some View { self.onboardingPage { - Text("Install the CLI") + Text("Setting up OpenClaw") .font(.largeTitle.weight(.semibold)) - Text("Required for local mode: installs `openclaw` so launchd can run the gateway.") + Text("OpenClaw is installing the local Gateway and its managed runtime.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -642,33 +645,36 @@ extension OnboardingView { .fixedSize(horizontal: false, vertical: true) self.onboardingCard(spacing: 10) { - HStack(spacing: 12) { - Button { - Task { await self.installCLI() } - } label: { - let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI" - ZStack { - Text(title) - .opacity(self.installingCLI ? 0 : 1) - if self.installingCLI { - ProgressView() - .controlSize(.mini) - } + if !self.cliStatusKnown { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Checking existing setup…") + .font(.headline) + } + } else if self.installingCLI { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Installing the Gateway…") + .font(.headline) + } + } else if self.cliInstalled, let loc = self.cliInstallLocation { + Label("Gateway installed", systemImage: "checkmark.circle.fill") + .font(.headline) + .foregroundStyle(.green) + Text(loc) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } else { + HStack { + Button("Retry setup", action: self.startCLIInstall) + .buttonStyle(.borderedProminent) + Button("Check again") { + Task { await self.refreshCLIStatus() } } - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.installingCLI) - - Button(self.copied ? "Copied" : "Copy install command") { - self.copyToPasteboard(self.devLinkCommand) - } - .disabled(self.installingCLI) - - if self.cliInstalled, let loc = self.cliInstallLocation { - Label("Installed at \(loc)", systemImage: "checkmark.circle.fill") - .font(.footnote) - .foregroundStyle(.green) + .buttonStyle(.bordered) } } @@ -676,15 +682,11 @@ extension OnboardingView { Text(cliStatus) .font(.caption) .foregroundStyle(.secondary) - } else if !self.cliInstalled, self.cliInstallLocation == nil { - Text( - """ - Installs a user-space Node 22.19+ runtime and the CLI (no Homebrew). - Rerun anytime to reinstall or update. - """) - .font(.footnote) - .foregroundStyle(.secondary) } + + Text("Uses a private user-space install. No Terminal, administrator access, or Homebrew required.") + .font(.footnote) + .foregroundStyle(.secondary) } } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift index 0c77f1e327dd..8b2dd8a13839 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift @@ -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() diff --git a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift index 0b6df7ddda3b..e0c63b467ede 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift @@ -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 diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index 6b4ad967cf5f..289fcefb7eed 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -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) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index a18d7071c46c..c12ae49e4f3c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -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) diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index da489e8bc885..2b8be4a98f1b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -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, diff --git a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift index c82ef0718c9d..37c22f79245b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NodePairingApprovalPrompterTests.swift @@ -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() } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 5b816d3cd5a8..84819bca34d2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -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)") diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift index 950c31c99516..16765d6304cb 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceIdentity.swift @@ -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" } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift index da6cd7d031de..16a56b8656f0 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeviceIdentityStoreTests.swift @@ -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) } diff --git a/docs/docs_map.md b/docs/docs_map.md index 86eed4b69b04..96f0bacf50e0 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -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 diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index d5e09484c27e..06f76e86b2f5 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -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@ ``` -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 diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 000594a628ec..9f3b898aaefe 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -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@ diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index fe965af080c6..8fc3b5857208 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -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 diff --git a/scripts/install-cli.sh b/scripts/install-cli.sh index fe6ce6a58168..4513bff2f908 100755 --- a/scripts/install-cli.sh +++ b/scripts/install-cli.sh @@ -1183,8 +1183,6 @@ main() { RUN_ONBOARD=0 fi - cleanup_legacy_submodules - PATH="$(node_dir)/bin:${PREFIX}/bin:${PATH}" export PATH diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index b65cbfc5a49e..0cce5f47d6ce 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -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" diff --git a/test/scripts/install-cli.test.ts b/test/scripts/install-cli.test.ts index 5bbc636ce27e..3642dfc37d64 100644 --- a/test/scripts/install-cli.test.ts +++ b/test/scripts/install-cli.test.ts @@ -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', diff --git a/test/scripts/package-mac-app.test.ts b/test/scripts/package-mac-app.test.ts index 8cea2293fbdb..430d9b004acb 100644 --- a/test/scripts/package-mac-app.test.ts +++ b/test/scripts/package-mac-app.test.ts @@ -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(