Compare commits

..

125 Commits

Author SHA1 Message Date
Glucksberg
2892dbbc7b fix(exec): resolve PATH key case-insensitively for Windows pathPrepend (#25399) 2026-03-02 08:30:12 -06:00
Peter Steinberger
cd18472405 test(perf): trim redundant cron regression setup coverage 2026-03-02 14:25:49 +00:00
Tak Hoffman
2a11a20fe2 test(windows): stabilize exec wrapper mock assertions (#31771) 2026-03-02 08:24:49 -06:00
Peter Steinberger
2a2a9902d9 test(perf): merge isolated-agent model precedence cases 2026-03-02 14:24:32 +00:00
Peter Steinberger
5561a6b659 test(perf): dedupe isolated-agent delivery announce cases 2026-03-02 14:24:32 +00:00
Peter Machona
c2d41dc473 fix(daemon): recover Windows restarts from unknown stale listeners (openclaw#24734) thanks @chilu18
Verified:
- pnpm vitest src/cli/daemon-cli/restart-health.test.ts src/cli/gateway-cli.coverage.test.ts
- pnpm oxfmt --check src/cli/daemon-cli/restart-health.ts src/cli/daemon-cli/restart-health.test.ts
- pnpm check (fails on unrelated repo baseline tsgo errors in extensions/* and src/process/exec.windows.test.ts)

Co-authored-by: chilu18 <7957943+chilu18@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:24:25 -06:00
Peter Steinberger
a05b8f47b1 test(perf): tighten cron regression timeout windows 2026-03-02 14:20:31 +00:00
Peter Steinberger
7d600ff4e2 test(perf): dedupe plugin validation scenarios 2026-03-02 14:20:21 +00:00
Peter Steinberger
38bdb0d271 test(perf): prune redundant preaction command-path cases 2026-03-02 14:14:02 +00:00
Peter Steinberger
32475448eb test(perf): trim ios team-id fixture setup 2026-03-02 14:12:26 +00:00
Fologan
8421b2e848 fix(gateway): avoid stale running status from Windows Scheduled Task (openclaw#19504) thanks @Fologan
Verified:
- pnpm vitest src/daemon/schtasks.test.ts
- pnpm check
- pnpm build

Co-authored-by: Fologan <164580328+Fologan@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:12:24 -06:00
Peter Steinberger
f2468feb86 test(perf): use shell resolver fixture in secrets audit 2026-03-02 14:10:53 +00:00
Tak Hoffman
1fe0f848df fix(slack): type message.channels/group handlers (#31758) 2026-03-02 08:09:49 -06:00
Peter Steinberger
98e5851d8a test(perf): collapse overlapping preaction scenarios 2026-03-02 14:07:06 +00:00
Tak Hoffman
cd653c55d7 windows: unify non-core spawn handling across acp qmd and docker (openclaw#31750) thanks @Takhoffman
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check (fails on pre-existing unrelated src/slack/monitor/events/messages.ts typing errors)
- pnpm vitest run src/acp/client.test.ts src/memory/qmd-manager.test.ts src/agents/sandbox/docker.execDockerRaw.enoent.test.ts src/agents/sandbox/docker.windows.test.ts extensions/acpx/src/runtime-internals/process.test.ts

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 08:05:39 -06:00
Peter Steinberger
32c7242974 test(perf): simplify ios team-id fixtures 2026-03-02 14:05:08 +00:00
Peter Steinberger
534f436d4e test(perf): reduce repeated cli program setup overhead 2026-03-02 14:02:47 +00:00
Peter Steinberger
234e07fcc0 refactor(process): extract command env resolution helper 2026-03-02 14:02:47 +00:00
Peter Steinberger
9eb70d2725 fix: add proxy-bypass regression + changelog (#31469) (thanks @widingmarcus-cyber) 2026-03-02 13:56:30 +00:00
Marcus Widing
2bec80cd97 fix: preserve user-configured NO_PROXY when loopback already covered
Only restore env vars when we actually modified them (noProxyDidModify
flag). Prevents silently deleting a user's NO_PROXY that already
contains loopback entries. Added regression test.
2026-03-02 13:56:30 +00:00
Marcus Widing
dd8c76110f fix: remove isFirst guard from NO_PROXY restore, add reverse-exit test
Fix Greptile review: when call A exits before call B, the isFirst flag
on B is false, so the restore condition (refCount===0 && isFirst) was
never true and NO_PROXY leaked permanently.

Remove '&& isFirst' so any last exiter (refCount===0) restores the
original env vars. Added explicit reverse-exit-order regression test.
2026-03-02 13:56:30 +00:00
Marcus Widing
158709ff62 fix: make withNoProxyForLocalhost reentrant-safe, fix [::1] check
Address Greptile review feedback:
- Replace snapshot/restore pattern with reference counter to prevent
  permanent NO_PROXY env-var leak under concurrent async calls
- Include [::1] in the alreadyCoversLocalhost guard
- Add concurrency regression test
2026-03-02 13:56:30 +00:00
Marcus Widing
c96234b51d fix: bypass proxy for CDP localhost connections (#31219)
When HTTP_PROXY / HTTPS_PROXY / ALL_PROXY environment variables are set,
CDP connections to localhost/127.0.0.1 can be incorrectly routed through
the proxy (e.g. via global-agent or undici proxy dispatcher), causing
browser control to fail.

Fix:
- New cdp-proxy-bypass module with utilities for direct localhost connections
- WebSocket (ws) CDP connections: pass explicit http.Agent to bypass any
  global proxy agent patching
- fetch-based CDP probes: wrap in withNoProxyForLocalhost() to temporarily
  set NO_PROXY for the duration of the call
- Playwright connectOverCDP: wrap in withNoProxyForLocalhost() since
  Playwright reads env vars internally
- 13 new tests covering getDirectAgentForCdp, hasProxyEnv, and
  withNoProxyForLocalhost (env save/restore, error recovery)
2026-03-02 13:56:30 +00:00
Peter Steinberger
1184d39e1d fix: extend managed-tab cap regressions + changelog (#29724) (thanks @pandego) 2026-03-02 13:55:09 +00:00
pandego
e303b356ba fix(browser): detach tab-cap listing from openTab return path 2026-03-02 13:55:09 +00:00
pandego
22ec577d80 fix(browser): require managed runtime ownership for tab cap cleanup 2026-03-02 13:55:09 +00:00
pandego
9b938f2bf6 fix(browser): skip tab cap cleanup for attach-only profiles 2026-03-02 13:55:09 +00:00
pandego
c7bf54b914 fix(browser): scope tab cap to local profile and detach cleanup closes 2026-03-02 13:55:09 +00:00
pandego
c350dc8a7b fix(browser): keep tab-cap cleanup best-effort on list errors 2026-03-02 13:55:09 +00:00
pandego
b47dc73b70 fix(browser): cap managed profile tabs to prevent renderer buildup 2026-03-02 13:55:09 +00:00
Keenan
050e928985 [codex] Fix main-session web UI reply routing to Telegram (openclaw#29328) thanks @BeeSting50
Verified:
- pnpm test src/auto-reply/reply/dispatch-from-config.test.ts src/gateway/server-methods/chat.directive-tags.test.ts
- pnpm exec oxfmt --check src/auto-reply/reply/dispatch-from-config.test.ts src/gateway/server-methods/chat.directive-tags.test.ts src/auto-reply/reply/dispatch-from-config.ts src/gateway/server-methods/chat.ts CHANGELOG.md
- CI note: non-required check "check" failed on unrelated src/slack/monitor/events/messages.ts TS errors outside this PR scope.

Co-authored-by: BeeSting50 <85285887+BeeSting50@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:54:16 -06:00
Peter Steinberger
99ee26d534 fix: add timeout cleanup regression for browser CDP readiness (#29538) (thanks @AaronWander) 2026-03-02 13:53:21 +00:00
AaronWander
8bccb0032a fix(browser): bound post-launch CDP wait by elapsed time (#21149) 2026-03-02 13:53:21 +00:00
AaronWander
d06cc77f38 fix(browser): wait for CDP readiness after start (#21149) 2026-03-02 13:53:21 +00:00
Peter Steinberger
0d620a56e2 test(refactor): reuse shared program setup in preaction tests 2026-03-02 13:53:10 +00:00
Peter Steinberger
09748ab109 test(perf): speed up supervisor and exec process tests 2026-03-02 13:53:10 +00:00
Peter Steinberger
2d8b8a17ab test(android): dedupe node and gateway invoke tests 2026-03-02 13:52:36 +00:00
Rain120
6ea6aca5bd fix(ui): the header has been hidden by content in the config page 2026-03-02 07:52:26 -06:00
Sid
7b5a410b83 fix(node-host): decode Windows exec output with active code page (openclaw#30652) thanks @Sid-Qin
Verified:
- pnpm vitest run src/node-host/invoke.sanitize-env.test.ts src/node-host/invoke-system-run.test.ts

Co-authored-by: Sid-Qin <53659198+Sid-Qin@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:50:17 -06:00
icesword0760
6e008e93be Process: fix Windows .cmd spawn EINVAL (openclaw#29759) thanks @icesword0760
Verified:
- pnpm vitest run src/process/exec.test.ts src/process/exec.windows.test.ts

Co-authored-by: icesword0760 <123886211+icesword0760@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:49:54 -06:00
SidQin-cyber
732c4f3921 fix(browser): retry chrome act when target tab is stale
When a Chrome relay targetId becomes stale between snapshot and action,
the browser tool now retries once without targetId so the relay falls
back to the currently attached tab.

Drop the unknown recovered field from the test mock return value
to satisfy tsc strict checking against BrowserActResponse.
2026-03-02 13:49:33 +00:00
leotwang
910c654807 test(config): add schema regression tests for browser.extraArgs 2026-03-02 13:47:59 +00:00
leotwang
925117d277 config: add extraArgs to browser zod schema 2026-03-02 13:47:59 +00:00
Yasunori Morishima(盛島康徳)
be8930d6f9 fix: clear stale runningAtMs in cron.run() before already-running check (#17949)
Add recomputeNextRunsForMaintenance() call in run() so that stale
runningAtMs markers (from a crashed Phase-1 persist) are cleared by the
existing normalizeJobTickState logic before the already-running guard.

Without this, a manual cron.run() could be blocked for up to
STUCK_RUN_MS (2 hours) even though no job was actually running.

Fixes #17554

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:47:36 -06:00
Peter Steinberger
60b8d645de test(perf): standardize loader fixtures to cjs 2026-03-02 13:43:55 +00:00
Mark L
097ad88f9d fix(feishu): tolerate missing webhook defaults in older plugin-sdk (openclaw#31639) thanks @liuxiaopai-ai
Verified:
- pnpm test extensions/feishu/src/monitor.state.defaults.test.ts
- pnpm exec vitest run extensions/feishu/src/monitor.state.defaults.test.ts
- pnpm exec oxfmt --check extensions/feishu/src/monitor.state.ts extensions/feishu/src/monitor.state.defaults.test.ts CHANGELOG.md
- CI note: non-required check "check" failed on unrelated  TS errors outside this PR scope.

Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:42:16 -06:00
Kate Chapman
6df8bd9741 fix(cron): wrap computeJobNextRunAtMs in try-catch inside applyJobResult (#30905)
* fix(cron): wrap computeJobNextRunAtMs in try-catch inside applyJobResult

Without this guard, if the croner library throws during schedule
computation (timezone/expression edge cases), the exception propagates
out of applyJobResult and the entire state update is lost — runningAtMs
never clears, lastRunAtMs never advances, nextRunAtMs never recomputes.
After STUCK_RUN_MS (2h), stuck detection clears runningAtMs and the job
re-fires, creating a ~2h repeat cycle instead of the intended schedule.

The sibling function recomputeJobNextRunAtMs in jobs.ts already wraps
computeJobNextRunAtMs in try-catch; this was an oversight in the
applyJobResult call sites.

Changes:
- Error-backoff path: catch and fall back to backoff-only schedule
- Success path: catch and fall through to the MIN_REFIRE_GAP_MS safety net
- applyOutcomeToStoredJob: log a warning when job not found after forceReload

* fix(cron): use recordScheduleComputeError in applyJobResult catch blocks

Address review feedback: the original catch blocks only logged a warning,
which meant a persistent computeJobNextRunAtMs throw would cause a
MIN_REFIRE_GAP_MS (2s) hot loop on cron-kind jobs.

Now both catch blocks call recordScheduleComputeError (exported from
jobs.ts), which tracks consecutive schedule errors and auto-disables the
job after 3 failures — matching the existing behavior in
recomputeJobNextRunAtMs.

* test(cron): cover applyJobResult schedule-throw fallback paths

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:38:33 -06:00
Andrey
21e19e42a3 fix(cron): skip isError payloads when picking summary/delivery content (#21454)
* fix(cron): skip isError payloads when picking summary/delivery content

buildEmbeddedRunPayloads appends isError warnings as the last payload.
Three functions in helpers.ts iterate last-to-first and pick the error
over real agent output. Use two-pass selection: prefer non-error payloads,
fall back to error-only when no real content exists.

Fixes: pickSummaryFromPayloads, pickLastNonEmptyTextFromPayloads,
pickLastDeliverablePayload — all now accept and filter isError.

* Changelog: note cron payload isError filtering (#21454)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:38:05 -06:00
Peter Steinberger
2c192a3795 test(perf): reduce cron overlap timer advance slack 2026-03-02 13:37:26 +00:00
Peter Steinberger
02bd7a2249 test(perf): use CJS fixtures in plugin loader tests 2026-03-02 13:36:17 +00:00
Jared Grimes
aa5d173bec fix(feishu): prevent duplicate delivery when message tool uses generic provider (openclaw#31538) thanks @jlgrimes
Verified:
- pnpm exec vitest run src/auto-reply/reply/agent-runner-payloads.test.ts src/auto-reply/reply/followup-runner.test.ts
- pnpm check (fails on unrelated baseline type errors outside PR scope)

Co-authored-by: jlgrimes <8084595+jlgrimes@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:35:58 -06:00
liuxiaopai-ai
06306501ab Slack: register typed channel/group message event handlers 2026-03-02 13:32:54 +00:00
Yuzuru Suzuki
6513c42d2d fix(cron): treat announce delivery failure as ok when execution succeeded (#31082)
* cron: treat announce delivery failure as ok when agent execution succeeded

* fix: set delivered:false and error on announce delivery failure paths

* Changelog: note cron announce delivery status handling (#31082)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:27:57 -06:00
Peter Steinberger
16e85360a1 perf(cli): cache preaction lazy module imports 2026-03-02 13:26:54 +00:00
Peter Steinberger
4d31c29a19 test(perf): skip shell profile loading in ios team-id script tests 2026-03-02 13:25:49 +00:00
Peter Steinberger
79cb5e2c9b test(perf): trim cron regression timeout windows 2026-03-02 13:25:49 +00:00
kleebaker
b40d5817a2 fix(cron): avoid 30s timeout for cron run --expect-final (#29942)
* fix(cron): use longer default timeout for cron run --expect-final

* test(cron-cli): stabilize cron run timeout assertions with explicit run exits

---------

Co-authored-by: Kelly Baker <kelly@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-02 07:24:42 -06:00
Tak Hoffman
254bb7ceee ui(cron): add advanced controls for run-if-due and routing (#31244)
* ui(cron): add advanced run controls and routing fields

* ui(cron): gate delivery account id to announce mode

* ui(cron): allow clearing delivery account id in editor

* cron: persist payload lightContext updates

* tests(cron): fix payload lightContext assertion typing
2026-03-02 07:24:33 -06:00
cygaar
127217612c fix(CI/CD): use path.resolve in expandHomePrefix test for Windows compat (#30961)
Merged via squash.

Prepared head SHA: 26bc118517
Co-authored-by: cygaar <97691933+cygaar@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-02 14:18:11 +01:00
Peter Steinberger
0b762e9a02 fix(android): import remember for pending tools bubble 2026-03-02 13:11:08 +00:00
Peter Steinberger
cb9bce902e fix(infra): accept cross-realm promises in boundary traversal 2026-03-02 13:00:21 +00:00
Peter Steinberger
848ade07da test(cli): fix gateway coverage mock signature 2026-03-02 13:00:21 +00:00
Peter Steinberger
a9d572394e test(perf): tighten exec timeout slack in non-flaky cases 2026-03-02 12:58:00 +00:00
Peter Steinberger
b02b94673f refactor: dedupe runtime and helper flows 2026-03-02 12:55:47 +00:00
Peter Steinberger
5d3f066bbd test(perf): reduce boundary-path fuzz setup churn 2026-03-02 12:54:59 +00:00
Peter Steinberger
6adc93cc92 test(perf): skip scheduler startup in cron delivery-plan tests 2026-03-02 12:54:53 +00:00
Peter Steinberger
e99928f3f1 test(perf): use git ls-files fast path for guardrail source scan 2026-03-02 12:42:02 +00:00
Peter Steinberger
afda085b39 test(perf): disable scheduler startup in manual-only cron regressions 2026-03-02 12:41:56 +00:00
Peter Steinberger
3980c315d1 test(perf): avoid real node startup in pre-commit hook integration 2026-03-02 12:41:51 +00:00
Peter Steinberger
7b38e8231e test(perf): stub expensive cli coverage integration paths 2026-03-02 12:41:45 +00:00
Peter Steinberger
f94d6fb1f1 test(perf): stub pre-commit helpers in hook integration test 2026-03-02 12:27:37 +00:00
Peter Steinberger
5fed91e624 test(perf): avoid real python startup in ios team-id integration case 2026-03-02 12:26:38 +00:00
Peter Steinberger
ba3957ad77 test(perf): bypass daemon install token-generation path in coverage test 2026-03-02 12:24:03 +00:00
Peter Steinberger
916b0e6609 test(perf): tighten cron regression timeout constants 2026-03-02 12:21:35 +00:00
Peter Steinberger
099b11fc7d test(perf): align media auto-detect no-key mock with scenario 2026-03-02 12:20:51 +00:00
Peter Steinberger
f7b8e4be27 test(fix): stabilize exec no-output heartbeat timing case 2026-03-02 12:18:27 +00:00
Peter Steinberger
2cda78a0b0 test(perf): stub docker probes in filesystem audit cases 2026-03-02 12:18:27 +00:00
Peter Steinberger
87316e07d8 refactor(macos): share pairing and ui dedupe utilities 2026-03-02 12:13:45 +00:00
Peter Steinberger
d85d3c88d5 refactor(agents): centralize tool display definitions 2026-03-02 12:13:45 +00:00
Peter Steinberger
d977af5853 refactor(diffs): share artifact detail and screenshot test helpers 2026-03-02 12:13:45 +00:00
Peter Steinberger
7533015532 refactor(android): extract shared dedupe helpers for node and chat 2026-03-02 12:13:45 +00:00
Peter Steinberger
f01862bce2 test(perf): clear concurrent-start timeout handle in cron regression test 2026-03-02 12:07:38 +00:00
Peter Steinberger
8e0ca219a4 test(perf): precreate plugin config validation fixtures 2026-03-02 12:06:48 +00:00
Peter Steinberger
bdfd3bae6f test(perf): reuse cli programs in coverage tests 2026-03-02 12:00:28 +00:00
Peter Steinberger
adf2ef88c6 test(perf): simplify temp-path guard scan loop 2026-03-02 11:59:24 +00:00
Peter Steinberger
d95bc10425 test(perf): streamline deep code-safety audit assertions 2026-03-02 11:58:49 +00:00
Peter Steinberger
d9ff3bf1af test(perf): tighten process exec and supervisor timing fixtures 2026-03-02 11:56:57 +00:00
Peter Steinberger
2b855704da test(perf): remove redundant ios team-id script invocation 2026-03-02 11:55:35 +00:00
Peter Steinberger
c80a332def test(perf): cut cron retry waits and tighten tmp guard prefilter 2026-03-02 11:54:26 +00:00
Peter Steinberger
d9a8d3853d test(perf): trim qmd manager fixture setup overhead 2026-03-02 11:54:21 +00:00
Peter Steinberger
94e480f64a test(refactor): dedupe preaction command coverage 2026-03-02 11:41:40 +00:00
Peter Steinberger
735216f7e4 test(perf): reduce security audit and guardrail overhead 2026-03-02 11:41:33 +00:00
Peter Steinberger
316875582a test(perf): speed up pre-commit integration setup 2026-03-02 11:36:16 +00:00
Peter Steinberger
43bffe7bdc test(perf): cache plugin fixtures and streamline shell tests 2026-03-02 11:35:13 +00:00
Peter Steinberger
cf67e374c0 refactor(macos): dedupe UI, pairing, and runtime helpers 2026-03-02 11:32:20 +00:00
Peter Steinberger
cd011897d0 refactor(ios): dedupe status, gateway, and service flows 2026-03-02 11:32:20 +00:00
Peter Steinberger
2ca5722221 refactor(shared): dedupe common OpenClawKit helpers 2026-03-02 11:32:20 +00:00
Peter Steinberger
3dd01c3361 test(perf): reuse shared temp root in plugin install tests 2026-03-02 11:27:04 +00:00
Peter Steinberger
79b649a25e test: fix signal-listener typing in exec bridge test 2026-03-02 11:22:26 +00:00
Peter Steinberger
0c2d85529a test(refactor): dedupe cli and ios script scenarios 2026-03-02 11:16:33 +00:00
Peter Steinberger
1b98879295 test(perf): reduce guardrail and media test overhead 2026-03-02 11:16:29 +00:00
Peter Steinberger
bff785aecc test(perf): tighten process test timeouts and fs setup 2026-03-02 11:16:24 +00:00
Peter Steinberger
4dcb16d696 ci: fix install smoke docker helper path 2026-03-02 11:01:56 +00:00
Peter Steinberger
96ef6ea3cf test(perf): dedupe setup in cli/security script suites 2026-03-02 10:53:21 +00:00
Peter Steinberger
4a8ada662e test(perf): cache media fixtures and trim timeout waits 2026-03-02 10:52:58 +00:00
Peter Steinberger
8a1465c314 test(perf): trim timer-heavy suites and guardrail scanning 2026-03-02 10:28:39 +00:00
Peter Steinberger
f5a265a51a test(sessions): normalize cross-agent path assertions 2026-03-02 10:08:52 +00:00
Peter Steinberger
033c731f19 fix(ci): annotate feishu hoisted mock type 2026-03-02 09:59:16 +00:00
Peter Steinberger
c1a46301b6 fix(ci): align strict nullable typing across channels and ui 2026-03-02 09:56:14 +00:00
Peter Steinberger
fc692d82fd refactor(tests): dedupe macos ipc smoke setup blocks 2026-03-02 09:55:46 +00:00
Peter Steinberger
8553d22428 refactor(tests): dedupe ios gateway and deeplink fixtures 2026-03-02 09:55:46 +00:00
Peter Steinberger
7d44b753ff refactor(tests): dedupe openclawkit chat test helpers 2026-03-02 09:55:46 +00:00
Peter Steinberger
04030ddf68 test(runtime): trim timer-heavy regression suites 2026-03-02 09:47:29 +00:00
Peter Steinberger
fd4d157e45 test(config): reuse fixtures for faster validation 2026-03-02 09:47:29 +00:00
Peter Steinberger
fcb956a0a2 test(cli): reduce update/program suite overhead 2026-03-02 09:46:27 +00:00
Peter Steinberger
500883775b refactor(tests): dedupe ios defaults and setup-code helpers 2026-03-02 09:39:45 +00:00
Peter Steinberger
fd7774a79e refactor(tests): dedupe swift gateway and chat fixtures 2026-03-02 09:39:45 +00:00
Gustavo Madeira Santana
5f49a5da3c Diffs: extend image quality configs and add PDF as a format option (#31342)
Merged via squash.

Prepared head SHA: cc12097851
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-02 04:38:50 -05:00
Peter Steinberger
756f9c9fef refactor(scripts): dedupe installer CLI verification 2026-03-02 08:59:33 +00:00
Peter Steinberger
ad8d766f65 refactor(extensions): dedupe channel config, onboarding, and monitors 2026-03-02 08:54:20 +00:00
Peter Steinberger
d358b3ac88 refactor(core): extract shared usage, auth, and display helpers 2026-03-02 08:54:20 +00:00
Peter Steinberger
e427826fcf refactor(ui): dedupe state, views, and usage helpers 2026-03-02 08:54:20 +00:00
Peter Steinberger
00a2456b72 refactor(scripts): dedupe guard checks and smoke helpers 2026-03-02 08:54:20 +00:00
Vincent Koc
5d53b61d9e fix(browser): honor profile attachOnly for loopback CDP (#31429)
* config(browser): allow profile attachOnly field

* config(schema): accept profile attachOnly

* browser(config): resolve per-profile attachOnly

* browser(runtime): honor profile attachOnly checks

* browser(routes): expose profile attachOnly in status

* config(labels): add browser profile attachOnly label

* config(help): document browser profile attachOnly

* test(config): cover profile attachOnly resolution

* test(browser): cover profile attachOnly runtime path

* test(config): include profile attachOnly help target

* changelog: note profile attachOnly override

* browser(runtime): prioritize attachOnly over loopback ownership error

* test(browser): cover attachOnly ws-failure ownership path
2026-03-02 00:49:57 -08:00
Vincent Koc
29c3ce9454 [AI-assisted] test: fix typing and test fixture issues (#31444)
* test: fix typing and test fixture issues

* Fix type-test harness issues from session routing and mock typing

* Add routing regression test for session.mainKey precedence
2026-03-02 00:41:21 -08:00
401 changed files with 13602 additions and 10260 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.
@@ -40,6 +41,9 @@ Docs: https://docs.openclaw.ai
### Fixes
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
@@ -52,15 +56,20 @@ Docs: https://docs.openclaw.ai
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
@@ -70,6 +79,7 @@ Docs: https://docs.openclaw.ai
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
@@ -203,6 +213,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman.
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
- Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra.
- Cron/Isolated payload selection: ignore `isError` payloads when deriving summary/output/delivery payload fallbacks, while preserving error-only fallback behavior when no non-error payload exists. (#21454) Thanks @Diaspar4u.
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.
- Cron/Timer hot-loop guard: enforce a minimum timer re-arm delay when stale past-due jobs would otherwise trigger repeated `setTimeout(0)` loops, preventing event-loop saturation and log-flood behavior. (#29853) Thanks @FlamesCN.
- Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn.
@@ -259,6 +270,7 @@ Docs: https://docs.openclaw.ai
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.
- Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf.
- Cron tool/update flat params: recover top-level update patch fields when models omit the `patch` wrapper, and allow flattened update keys through tool input schema validation so `cron.update` no longer fails with `patch required` for valid flat payloads. (#23221)
- Cron/Announce delivery status: keep isolated cron runs in `ok` state when execution succeeds but announce delivery fails (for example transient `pairing required`), while preserving `delivered=false` and delivery error context for visibility. (#31082) Thanks @YuzuruS.
- Agents/Message tool scoping: include other configured channels in scoped `message` tool action enum + description so isolated/cron runs can discover and invoke cross-channel actions without schema validation failures. Landed from contributor PR #20840 by @altaywtf. Thanks @altaywtf.
- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington.
- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11.

View File

@@ -44,6 +44,14 @@ class CanvasController {
return (q * 100.0).toInt().coerceIn(1, 100)
}
private fun Bitmap.scaleForMaxWidth(maxWidth: Int?): Bitmap {
if (maxWidth == null || maxWidth <= 0 || width <= maxWidth) {
return this
}
val scaledHeight = (height.toDouble() * (maxWidth.toDouble() / width.toDouble())).toInt().coerceAtLeast(1)
return scale(maxWidth, scaledHeight)
}
fun attach(webView: WebView) {
this.webView = webView
reload()
@@ -148,13 +156,7 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
bmp.scale(maxWidth, h)
} else {
bmp
}
val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
@@ -165,13 +167,7 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
val scaled =
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
bmp.scale(maxWidth, h)
} else {
bmp
}
val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
val (compressFormat, compressQuality) =

View File

@@ -248,30 +248,37 @@ private object SystemContactsDataSource : ContactsDataSource {
}
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER)
resolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
projection,
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
if (cursor == null) return emptyList()
val out = LinkedHashSet<String>()
while (cursor.moveToNext()) {
val value = cursor.getString(0)?.trim().orEmpty()
if (value.isNotEmpty()) out += value
}
return out.toList()
}
return queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
contactId = contactId,
)
}
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS)
return queryContactValues(
resolver = resolver,
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
contactId = contactId,
)
}
private fun queryContactValues(
resolver: ContentResolver,
contentUri: android.net.Uri,
valueColumn: String,
contactIdColumn: String,
contactId: Long,
): List<String> {
val projection = arrayOf(valueColumn)
resolver.query(
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
contentUri,
projection,
"${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?",
"$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->

View File

@@ -8,6 +8,7 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -33,6 +34,21 @@ data class DeviceNotificationEntry(
val isClearable: Boolean,
)
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
return buildJsonObject {
put("key", JsonPrimitive(key))
put("packageName", JsonPrimitive(packageName))
put("postTimeMs", JsonPrimitive(postTimeMs))
put("isOngoing", JsonPrimitive(isOngoing))
put("isClearable", JsonPrimitive(isClearable))
title?.let { put("title", JsonPrimitive(it)) }
text?.let { put("text", JsonPrimitive(it)) }
subText?.let { put("subText", JsonPrimitive(it)) }
category?.let { put("category", JsonPrimitive(it)) }
channelId?.let { put("channelId", JsonPrimitive(it)) }
}
}
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,

View File

@@ -10,7 +10,6 @@ import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawSystemCommand
@@ -146,7 +145,9 @@ class InvokeDispatcher(
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
// Photos command
OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
paramsJson,
)
// Contacts command
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)

View File

@@ -131,20 +131,7 @@ class NotificationsHandler private constructor(
put(
"notifications",
JsonArray(
snapshot.notifications.map { entry ->
buildJsonObject {
put("key", JsonPrimitive(entry.key))
put("packageName", JsonPrimitive(entry.packageName))
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
put("isOngoing", JsonPrimitive(entry.isOngoing))
put("isClearable", JsonPrimitive(entry.isClearable))
entry.title?.let { put("title", JsonPrimitive(it)) }
entry.text?.let { put("text", JsonPrimitive(it)) }
entry.subText?.let { put("subText", JsonPrimitive(it)) }
entry.category?.let { put("category", JsonPrimitive(it)) }
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
}
},
snapshot.notifications.map { entry -> entry.toJsonObject() },
),
)
}.toString()

View File

@@ -0,0 +1,42 @@
package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal data class Base64ImageState(
val image: ImageBitmap?,
val failed: Boolean,
)
@Composable
internal fun rememberBase64ImageState(base64: String): Base64ImageState {
var image by remember(base64) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(base64) { mutableStateOf(false) }
LaunchedEffect(base64) {
failed = false
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
null
}
}
if (image == null) failed = true
}
return Base64ImageState(image = image, failed = failed)
}

View File

@@ -1,7 +1,5 @@
package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -20,15 +18,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@@ -47,8 +40,6 @@ import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileTextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.commonmark.Extension
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
@@ -555,23 +546,8 @@ private data class ParsedDataImage(
@Composable
private fun InlineBase64Image(base64: String, mimeType: String?) {
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var failed by remember(base64) { mutableStateOf(false) }
LaunchedEffect(base64) {
failed = false
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
null
}
}
if (image == null) failed = true
}
val imageState = rememberBase64ImageState(base64)
val image = imageState.image
if (image != null) {
Image(
@@ -580,7 +556,7 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
} else if (failed) {
} else if (imageState.failed) {
Text(
text = "Image unavailable",
modifier = Modifier.padding(vertical = 2.dp),

View File

@@ -1,7 +1,5 @@
package ai.openclaw.android.ui.chat
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@@ -16,16 +14,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
@@ -51,8 +44,6 @@ import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private data class ChatBubbleStyle(
val alignEnd: Boolean,
@@ -241,23 +232,8 @@ private fun roleLabel(role: String): String {
@Composable
private fun ChatBase64Image(base64: String, mimeType: String?) {
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
var failed by remember(base64) { mutableStateOf(false) }
LaunchedEffect(base64) {
failed = false
image =
withContext(Dispatchers.Default) {
try {
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
bitmap.asImageBitmap()
} catch (_: Throwable) {
null
}
}
if (image == null) failed = true
}
val imageState = rememberBase64ImageState(base64)
val image = imageState.image
if (image != null) {
Surface(
@@ -273,7 +249,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
modifier = Modifier.fillMaxWidth(),
)
}
} else if (failed) {
} else if (imageState.failed) {
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
}
}

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
@@ -27,6 +28,10 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicReference
private const val TEST_TIMEOUT_MS = 8_000L
private const val CONNECT_CHALLENGE_FRAME =
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private val tokens = mutableMapOf<String, String>()
@@ -37,530 +42,301 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
}
}
private data class NodeHarness(
val session: GatewaySession,
val sessionJob: SupervisorJob,
)
private data class InvokeScenarioResult(
val request: GatewaySession.InvokeRequest,
val resultParams: JsonObject,
)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val handshakeOrigin = AtomicReference<String?>(null)
val lastDisconnect = AtomicReference("")
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
},
)
}
}
start()
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
GatewaySession.InvokeResult.ok("""{"handled":true}""")
},
)
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val req = withTimeout(8_000) { invokeRequest.await() }
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
assertEquals("invoke-1", req.id)
assertEquals("node-1", req.nodeId)
assertEquals("debug.ping", req.command)
assertEquals("""{"ping":"pong"}""", req.paramsJson)
assertNull(handshakeOrigin.get())
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
true,
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
)
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
assertEquals("invoke-1", result.request.id)
assertEquals("node-1", result.request.nodeId)
assertEquals("debug.ping", result.request.command)
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
assertNull(handshakeOrigin.get())
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
true,
result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
)
}
@Test
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val lastDisconnect = AtomicReference("")
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
},
)
}
}
start()
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\\"raw\\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
) {
GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
GatewaySession.InvokeResult.ok("""{"handled":true}""")
},
)
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val req = withTimeout(8_000) { invokeRequest.await() }
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
assertEquals("invoke-2", req.id)
assertEquals("node-2", req.nodeId)
assertEquals("debug.raw", req.command)
assertEquals("""{"raw":true}""", req.paramsJson)
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
assertEquals("invoke-2", result.request.id)
assertEquals("node-2", result.request.nodeId)
assertEquals("debug.raw", result.request.command)
assertEquals("""{"raw":true}""", result.request.paramsJson)
assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
}
@Test
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val connected = CompletableDeferred<Unit>()
val invokeResultParams = CompletableDeferred<String>()
val lastDisconnect = AtomicReference("")
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
webSocket.send(
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
},
)
}
}
start()
val result =
runInvokeScenario(
invokeEventFrame =
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
) {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = {
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
},
)
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
"CAMERA_PERMISSION_REQUIRED",
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
)
assertEquals(
"grant Camera permission",
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
)
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
}
assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals(
"CAMERA_PERMISSION_REQUIRED",
result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
)
assertEquals(
"grant Camera permission",
result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
)
}
@Test
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
val json = Json { ignoreUnknownKeys = true }
val json = testJson()
val connected = CompletableDeferred<Unit>()
val refreshRequestParams = CompletableDeferred<String?>()
val lastDisconnect = AtomicReference("")
val server =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
when (method) {
"connect" -> {
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
)
}
"node.canvas.capability.refresh" -> {
if (!refreshRequestParams.isCompleted) {
refreshRequestParams.complete(frame["params"]?.toString())
}
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
)
webSocket.close(1000, "done")
}
}
}
},
)
}
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
}
start()
"node.canvas.capability.refresh" -> {
if (!refreshRequestParams.isCompleted) {
refreshRequestParams.complete(frame["params"]?.toString())
}
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
)
webSocket.close(1000, "done")
}
}
}
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = deviceAuthStore,
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
)
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|${server.port}",
name = "test",
host = "127.0.0.1",
port = server.port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
assertEquals(true, refreshed)
assertEquals("{}", refreshParamsJson)
assertEquals(
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
session.currentCanvasHostUrl(),
harness.session.currentCanvasHostUrl(),
)
} finally {
session.disconnect()
sessionJob.cancelAndJoin()
server.shutdown()
shutdownHarness(harness, server)
}
}
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
private fun createNodeHarness(
connected: CompletableDeferred<Unit>,
lastDisconnect: AtomicReference<String>,
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): NodeHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
deviceAuthStore = InMemoryDeviceAuthStore(),
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
onDisconnected = { message ->
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
onInvoke = onInvoke,
)
return NodeHarness(session = session, sessionJob = sessionJob)
}
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
session.connect(
endpoint =
GatewayEndpoint(
stableId = "manual|127.0.0.1|$port",
name = "test",
host = "127.0.0.1",
port = port,
tlsEnabled = false,
),
token = "test-token",
password = null,
options =
GatewayConnectOptions(
role = "node",
scopes = listOf("node:invoke"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),
client =
GatewayClientInfo(
id = "openclaw-android-test",
displayName = "Android Test",
version = "1.0.0-test",
platform = "android",
mode = "node",
instanceId = "android-test-instance",
deviceFamily = "android",
modelIdentifier = "test",
),
),
tls = null,
)
}
private suspend fun awaitConnectedOrThrow(
connected: CompletableDeferred<Unit>,
lastDisconnect: AtomicReference<String>,
server: MockWebServer,
) {
val connectedWithinTimeout =
withTimeoutOrNull(TEST_TIMEOUT_MS) {
connected.await()
true
} == true
if (!connectedWithinTimeout) {
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
}
private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
harness.session.disconnect()
harness.sessionJob.cancelAndJoin()
server.shutdown()
}
private suspend fun runInvokeScenario(
invokeEventFrame: String,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
): InvokeScenarioResult {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
val invokeResultParams = CompletableDeferred<String>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(
json = json,
onHandshake = onHandshake,
) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
webSocket.send(invokeEventFrame)
}
"node.invoke.result" -> {
if (!invokeResultParams.isCompleted) {
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { req ->
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
onInvoke(req)
}
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
return InvokeScenarioResult(request = request, resultParams = resultParams)
} finally {
shutdownHarness(harness, server)
}
}
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
}
private fun startGatewayServer(
json: Json,
onHandshake: ((RecordedRequest) -> Unit)? = null,
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
): MockWebServer =
MockWebServer().apply {
dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
onHandshake?.invoke(request)
return MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(CONNECT_CHALLENGE_FRAME)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val frame = json.parseToJsonElement(text).jsonObject
if (frame["type"]?.jsonPrimitive?.content != "req") return
val id = frame["id"]?.jsonPrimitive?.content ?: return
val method = frame["method"]?.jsonPrimitive?.content ?: return
onRequestFrame(webSocket, id, method, frame)
}
},
)
}
}
start()
}
}

View File

@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class CalendarHandlerTest {
class CalendarHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleCalendarEvents_requiresPermission() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
@@ -83,8 +79,6 @@ class CalendarHandlerTest {
assertFalse(result.ok)
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeCalendarDataSource(

View File

@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class ContactsHandlerTest {
class ContactsHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleContactsSearch_requiresReadPermission() {
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
@@ -92,8 +88,6 @@ class ContactsHandlerTest {
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
assertEquals(1, source.addCalls)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeContactsDataSource(

View File

@@ -16,144 +16,106 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class InvokeCommandRegistryTest {
private val coreCapabilities =
setOf(
OpenClawCapability.Canvas.rawValue,
OpenClawCapability.Screen.rawValue,
OpenClawCapability.Device.rawValue,
OpenClawCapability.Notifications.rawValue,
OpenClawCapability.System.rawValue,
OpenClawCapability.AppUpdate.rawValue,
OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue,
)
private val optionalCapabilities =
setOf(
OpenClawCapability.Camera.rawValue,
OpenClawCapability.Location.rawValue,
OpenClawCapability.Sms.rawValue,
OpenClawCapability.VoiceWake.rawValue,
OpenClawCapability.Motion.rawValue,
)
private val coreCommands =
setOf(
OpenClawDeviceCommand.Status.rawValue,
OpenClawDeviceCommand.Info.rawValue,
OpenClawDeviceCommand.Permissions.rawValue,
OpenClawDeviceCommand.Health.rawValue,
OpenClawNotificationsCommand.List.rawValue,
OpenClawNotificationsCommand.Actions.rawValue,
OpenClawSystemCommand.Notify.rawValue,
OpenClawPhotosCommand.Latest.rawValue,
OpenClawContactsCommand.Search.rawValue,
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
"app.update",
)
private val optionalCommands =
setOf(
OpenClawCameraCommand.Snap.rawValue,
OpenClawCameraCommand.Clip.rawValue,
OpenClawCameraCommand.List.rawValue,
OpenClawLocationCommand.Get.rawValue,
OpenClawMotionCommand.Activity.rawValue,
OpenClawMotionCommand.Pedometer.rawValue,
OpenClawSmsCommand.Send.rawValue,
)
private val debugCommands = setOf("debug.logs", "debug.ed25519")
@Test
fun advertisedCapabilities_respectsFeatureAvailability() {
val capabilities =
InvokeCommandRegistry.advertisedCapabilities(
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = false,
motionPedometerAvailable = false,
debugBuild = false,
),
)
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
assertContainsAll(capabilities, coreCapabilities)
assertMissingAll(capabilities, optionalCapabilities)
}
@Test
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
val capabilities =
InvokeCommandRegistry.advertisedCapabilities(
NodeRuntimeFlags(
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = false,
),
)
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
}
@Test
fun advertisedCommands_respectsFeatureAvailability() {
val commands =
InvokeCommandRegistry.advertisedCommands(
NodeRuntimeFlags(
cameraEnabled = false,
locationEnabled = false,
smsAvailable = false,
voiceWakeEnabled = false,
motionActivityAvailable = false,
motionPedometerAvailable = false,
debugBuild = false,
),
)
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertFalse(commands.contains("debug.logs"))
assertFalse(commands.contains("debug.ed25519"))
assertTrue(commands.contains("app.update"))
assertContainsAll(commands, coreCommands)
assertMissingAll(commands, optionalCommands + debugCommands)
}
@Test
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
val commands =
InvokeCommandRegistry.advertisedCommands(
NodeRuntimeFlags(
defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
),
)
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
assertTrue(commands.contains("debug.logs"))
assertTrue(commands.contains("debug.ed25519"))
assertTrue(commands.contains("app.update"))
assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
}
@Test
@@ -174,4 +136,31 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
private fun defaultFlags(
cameraEnabled: Boolean = false,
locationEnabled: Boolean = false,
smsAvailable: Boolean = false,
voiceWakeEnabled: Boolean = false,
motionActivityAvailable: Boolean = false,
motionPedometerAvailable: Boolean = false,
debugBuild: Boolean = false,
): NodeRuntimeFlags =
NodeRuntimeFlags(
cameraEnabled = cameraEnabled,
locationEnabled = locationEnabled,
smsAvailable = smsAvailable,
voiceWakeEnabled = voiceWakeEnabled,
motionActivityAvailable = motionActivityAvailable,
motionPedometerAvailable = motionPedometerAvailable,
debugBuild = debugBuild,
)
private fun assertContainsAll(actual: List<String>, expected: Set<String>) {
expected.forEach { value -> assertTrue(actual.contains(value)) }
}
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
}
}

View File

@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class MotionHandlerTest {
class MotionHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleMotionActivity_requiresPermission() =
runTest {
@@ -86,8 +82,6 @@ class MotionHandlerTest {
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeMotionDataSource(

View File

@@ -0,0 +1,11 @@
package ai.openclaw.android.node
import android.content.Context
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
abstract class NodeHandlerRobolectricTest {
protected fun appContext(): Context = RuntimeEnvironment.getApplication()
}

View File

@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class PhotosHandlerTest {
class PhotosHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handlePhotosLatest_requiresPermission() {
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
@@ -63,8 +59,6 @@ class PhotosHandlerTest {
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
assertEquals(640, first.getValue("width").jsonPrimitive.int)
}
private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakePhotosDataSource(

View File

@@ -52,46 +52,27 @@ actor CameraController {
try await self.ensureAccess(for: .video)
let session = AVCaptureSession()
session.sessionPreset = .photo
guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
throw CameraError.cameraUnavailable
}
let input = try AVCaptureDeviceInput(device: device)
guard session.canAddInput(input) else {
throw CameraError.captureFailed("Failed to add camera input")
}
session.addInput(input)
let output = AVCapturePhotoOutput()
guard session.canAddOutput(output) else {
throw CameraError.captureFailed("Failed to add photo output")
}
session.addOutput(output)
output.maxPhotoQualityPrioritization = .quality
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
preferFrontCamera: facing == .front,
deviceId: params.deviceId,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: { setupError in
CameraError.captureFailed(setupError.localizedDescription)
})
let session = prepared.session
let output = prepared.output
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
await CameraCapturePipelineSupport.warmUpCaptureSession()
await Self.sleepDelayMs(delayMs)
let settings: AVCapturePhotoSettings = {
if output.availablePhotoCodecTypes.contains(.jpeg) {
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
}
return AVCapturePhotoSettings()
}()
settings.photoQualityPrioritization = .quality
var delegate: PhotoCaptureDelegate?
let rawData: Data = try await withCheckedThrowingContinuation { cont in
let d = PhotoCaptureDelegate(cont)
delegate = d
output.capturePhoto(with: settings, delegate: d)
let rawData = try await CameraCapturePipelineSupport.capturePhotoData(output: output) { continuation in
PhotoCaptureDelegate(continuation)
}
withExtendedLifetime(delegate) {}
let res = try PhotoCapture.transcodeJPEGForGateway(
rawData: rawData,
@@ -121,63 +102,36 @@ actor CameraController {
try await self.ensureAccess(for: .audio)
}
let session = AVCaptureSession()
session.sessionPreset = .high
guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
throw CameraError.cameraUnavailable
}
let cameraInput = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(cameraInput) else {
throw CameraError.captureFailed("Failed to add camera input")
}
session.addInput(cameraInput)
if includeAudio {
guard let mic = AVCaptureDevice.default(for: .audio) else {
throw CameraError.microphoneUnavailable
}
let micInput = try AVCaptureDeviceInput(device: mic)
if session.canAddInput(micInput) {
session.addInput(micInput)
} else {
throw CameraError.captureFailed("Failed to add microphone input")
}
}
let output = AVCaptureMovieFileOutput()
guard session.canAddOutput(output) else {
throw CameraError.captureFailed("Failed to add movie output")
}
session.addOutput(output)
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let movURL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
defer {
try? FileManager().removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL)
}
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let d = MovieFileDelegate(cont)
delegate = d
output.startRecording(to: movURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
let data = try Data(contentsOf: mp4URL)
let data = try await CameraCapturePipelineSupport.withWarmMovieSession(
preferFrontCamera: facing == .front,
deviceId: params.deviceId,
includeAudio: includeAudio,
durationMs: durationMs,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: Self.mapMovieSetupError) { output in
var delegate: MovieFileDelegate?
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
let d = MovieFileDelegate(cont)
delegate = d
output.startRecording(to: movURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
// Transcode .mov -> .mp4 for easier downstream handling.
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
return try Data(contentsOf: mp4URL)
}
return (
format: format.rawValue,
base64: data.base64EncodedString(),
@@ -196,22 +150,7 @@ actor CameraController {
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
switch status {
case .authorized:
return
case .notDetermined:
let ok = await withCheckedContinuation(isolation: nil) { cont in
AVCaptureDevice.requestAccess(for: mediaType) { granted in
cont.resume(returning: granted)
}
}
if !ok {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
case .denied, .restricted:
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
@unknown default:
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
@@ -233,12 +172,15 @@ actor CameraController {
return AVCaptureDevice.default(for: .video)
}
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
CameraCapturePipelineSupport.mapMovieSetupError(
setupError,
microphoneUnavailableError: .microphoneUnavailable,
captureFailed: { .captureFailed($0) })
}
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
switch position {
case .front: "front"
case .back: "back"
default: "unspecified"
}
CameraCapturePipelineSupport.positionLabel(position)
}
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
@@ -307,11 +249,6 @@ actor CameraController {
}
}
private nonisolated static func warmUpCaptureSession() async {
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
}
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return }
let maxDelayMs = 10 * 1000

View File

@@ -15,14 +15,7 @@ final class ContactsService: ContactsServicing {
}
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
let store = CNContactStore()
let status = CNContactStore.authorizationStatus(for: .contacts)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
let store = try await Self.authorizedStore()
let limit = max(1, min(params.limit ?? 25, 200))
@@ -47,14 +40,7 @@ final class ContactsService: ContactsServicing {
}
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
let store = CNContactStore()
let status = CNContactStore.authorizationStatus(for: .contacts)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
let store = try await Self.authorizedStore()
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -127,6 +113,18 @@ final class ContactsService: ContactsServicing {
}
}
private static func authorizedStore() async throws -> CNContactStore {
let store = CNContactStore()
let status = CNContactStore.authorizationStatus(for: .contacts)
let authorized = await Self.ensureAuthorization(store: store, status: status)
guard authorized else {
throw NSError(domain: "Contacts", code: 1, userInfo: [
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
])
}
return store
}
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
(values ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }

View File

@@ -53,23 +53,17 @@ final class GatewayDiscoveryModel {
self.appendDebugLog("start()")
for domain in OpenClawBonjour.gatewayServiceDomains {
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain),
using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
serviceType: OpenClawBonjour.gatewayServiceType,
domain: domain,
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
onState: { [weak self] state in
guard let self else { return }
self.statesByDomain[domain] = state
self.updateStatusText()
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
},
onResults: { [weak self] results in
guard let self else { return }
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint {
@@ -98,13 +92,10 @@ final class GatewayDiscoveryModel {
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
self.recomputeGateways()
}
}
})
self.browsers[domain] = browser
browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)"))
}
}

View File

@@ -1,4 +1,5 @@
import Foundation
import OpenClawKit
// NetService-based resolver for Bonjour services.
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
@@ -20,8 +21,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
}
func start(timeout: TimeInterval = 2.0) {
self.service.schedule(in: .main, forMode: .common)
self.service.resolve(withTimeout: timeout)
BonjourServiceResolverSupport.start(self.service, timeout: timeout)
}
func netServiceDidResolveAddress(_ sender: NetService) {
@@ -47,9 +47,6 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
}
private static func normalizeHost(_ raw: String?) -> String? {
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty { return nil }
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
BonjourServiceResolverSupport.normalizeHost(raw)
}
}

View File

@@ -3,7 +3,7 @@ import CoreLocation
import Foundation
@MainActor
final class LocationService: NSObject, CLLocationManagerDelegate {
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
enum Error: Swift.Error {
case timeout
case unavailable
@@ -17,21 +17,18 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
private var isMonitoringSignificantChanges = false
var locationManager: CLLocationManager {
self.manager
}
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? {
get { self.locationContinuation }
set { self.locationContinuation = newValue }
}
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(iOS 14.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
self.configureLocationManager()
}
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus {
@@ -62,25 +59,14 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
_ = params
return try await LocationCurrentRequest.resolve(
manager: self.manager,
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs,
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
try await self.withTimeout(timeoutMs: timeoutMs, operation: operation)
}
}
@@ -97,24 +83,13 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
}
private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
kCLLocationAccuracyKilometer
case .balanced:
kCLLocationAccuracyHundredMeters
case .precise:
kCLLocationAccuracyBest
}
}
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
{
self.stopLocationUpdates()
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
self.manager.pausesLocationUpdatesAutomatically = true
self.manager.allowsBackgroundLocationUpdates = true

View File

@@ -1,5 +1,6 @@
import Foundation
import Network
import OpenClawKit
import os
extension NodeAppModel {
@@ -11,24 +12,12 @@ extension NodeAppModel {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
if let host = base.host, Self.isLoopbackHost(host) {
if let host = base.host, LoopbackHost.isLoopback(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
}
private static func isLoopbackHost(_ host: String) -> Bool {
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return true }
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
return true
}
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
return true
}
return false
}
func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
await MainActor.run {

View File

@@ -41,15 +41,17 @@ private struct AutoDetectStep: View {
.foregroundStyle(.secondary)
}
Section("Connection status") {
ConnectionStatusBox(
statusLines: self.connectionStatusLines(),
secondaryLine: self.connectStatusText)
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button("Retry") {
self.resetConnectionState()
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.triggerAutoConnect()
}
.disabled(self.connectingGatewayID != nil)
@@ -94,15 +96,6 @@ private struct AutoDetectStep: View {
return nil
}
private func connectionStatusLines() -> [String] {
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
}
private func resetConnectionState() {
self.appModel.disconnectGateway()
self.connectStatusText = nil
self.connectingGatewayID = nil
}
}
private struct ManualEntryStep: View {
@@ -162,11 +155,10 @@ private struct ManualEntryStep: View {
.autocorrectionDisabled()
}
Section("Connection status") {
ConnectionStatusBox(
statusLines: self.connectionStatusLines(),
secondaryLine: self.connectStatusText)
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button {
@@ -185,7 +177,10 @@ private struct ManualEntryStep: View {
.disabled(self.connectingGatewayID != nil)
Button("Retry") {
self.resetConnectionState()
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.resetManualForm()
}
.disabled(self.connectingGatewayID != nil)
@@ -237,16 +232,6 @@ private struct ManualEntryStep: View {
return Int(trimmed.filter { $0.isNumber })
}
private func connectionStatusLines() -> [String] {
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
}
private func resetConnectionState() {
self.appModel.disconnectGateway()
self.connectStatusText = nil
self.connectingGatewayID = nil
}
private func resetManualForm() {
self.setupCode = ""
self.setupStatusText = nil
@@ -317,6 +302,38 @@ private struct ManualEntryStep: View {
// (GatewaySetupCode) decode raw setup codes.
}
private func gatewayConnectionStatusLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
}
private func resetGatewayConnectionState(
appModel: NodeAppModel,
connectStatusText: inout String?,
connectingGatewayID: inout String?)
{
appModel.disconnectGateway()
connectStatusText = nil
connectingGatewayID = nil
}
@ViewBuilder
private func gatewayConnectionStatusSection(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController,
secondaryLine: String?) -> some View
{
Section("Connection status") {
ConnectionStatusBox(
statusLines: gatewayConnectionStatusLines(
appModel: appModel,
gatewayController: gatewayController),
secondaryLine: secondaryLine)
}
}
private struct ConnectionStatusBox: View {
let statusLines: [String]
let secondaryLine: String?

View File

@@ -489,21 +489,7 @@ struct OnboardingWizardView: View {
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualTLS)
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
self.manualConnectButton
} header: {
Text("Developer Local")
} footer: {
@@ -631,24 +617,27 @@ struct OnboardingWizardView: View {
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
self.manualConnectButton
}
}
private var manualConnectButton: some View {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
self.manualHost = link.host
self.manualPort = link.port

View File

@@ -456,11 +456,7 @@ enum WatchPromptNotificationBridge {
) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
center.add(request) { error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: ())
}
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
}
}
}

View File

@@ -177,20 +177,7 @@ struct RootCanvas: View {
}
private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.gatewayServerName != nil { return .connected }
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
return .connecting
}
if text.localizedCaseInsensitiveContains("error") {
return .error
}
return .disconnected
GatewayStatusBuilder.build(appModel: self.appModel)
}
private func updateIdleTimer() {
@@ -343,82 +330,18 @@ private struct CanvasContent: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.confirmationDialog(
"Gateway",
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
Button("Open Settings") {
self.openSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.openSettings() })
}
private var statusActivity: StatusPill.Activity? {
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
if self.appModel.isBackgrounded {
return StatusPill.Activity(
title: "Foreground required",
systemImage: "exclamationmark.triangle.fill",
tint: .orange)
}
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
let gatewayLower = gatewayStatus.lowercased()
if gatewayLower.contains("repair") {
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
}
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
}
// Avoid duplicating the primary gateway status ("Connecting") in the activity slot.
if self.appModel.screenRecordActive {
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
}
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
let systemImage: String
let tint: Color?
switch cameraHUDKind {
case .photo:
systemImage = "camera.fill"
tint = nil
case .recording:
systemImage = "video.fill"
tint = .red
case .success:
systemImage = "checkmark.circle.fill"
tint = .green
case .error:
systemImage = "exclamationmark.triangle.fill"
tint = .red
}
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
}
if self.voiceWakeEnabled {
let voiceStatus = self.appModel.voiceWake.statusText
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
}
if voiceStatus == "Paused" {
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
if self.appModel.talkMode.isEnabled {
return nil
}
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
}
}
return nil
StatusActivityBuilder.build(
appModel: self.appModel,
voiceWakeEnabled: self.voiceWakeEnabled,
cameraHUDText: self.cameraHUDText,
cameraHUDKind: self.cameraHUDKind)
}
}

View File

@@ -70,38 +70,14 @@ struct RootTabs: View {
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
.confirmationDialog(
"Gateway",
.gatewayActionsDialog(
isPresented: self.$showGatewayActions,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
self.appModel.disconnectGateway()
}
Button("Open Settings") {
self.selectedTab = 2
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
onDisconnect: { self.appModel.disconnectGateway() },
onOpenSettings: { self.selectedTab = 2 })
}
private var gatewayStatus: StatusPill.GatewayState {
if self.appModel.gatewayServerName != nil { return .connected }
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
return .connecting
}
if text.localizedCaseInsensitiveContains("error") {
return .error
}
return .disconnected
GatewayStatusBuilder.build(appModel: self.appModel)
}
private var statusActivity: StatusPill.Activity? {

View File

@@ -35,7 +35,7 @@ final class ScreenController {
if let url = URL(string: trimmed),
!url.isFileURL,
let host = url.host,
Self.isLoopbackHost(host)
LoopbackHost.isLoopback(host)
{
// Never try to load loopback URLs from a remote gateway.
self.showDefaultCanvas()
@@ -87,25 +87,11 @@ final class ScreenController {
func applyDebugStatusIfNeeded() {
guard let webView = self.activeWebView else { return }
let enabled = self.debugStatusEnabled
let title = self.debugStatusTitle
let subtitle = self.debugStatusSubtitle
let js = """
(() => {
try {
const api = globalThis.__openclaw;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
}
if (!\(enabled ? "true" : "false")) return;
if (typeof api.setStatus === 'function') {
api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
}
} catch (_) {}
})()
"""
webView.evaluateJavaScript(js) { _, _ in }
WebViewJavaScriptSupport.applyDebugStatus(
webView: webView,
enabled: self.debugStatusEnabled,
title: self.debugStatusTitle,
subtitle: self.debugStatusSubtitle)
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
@@ -137,46 +123,11 @@ final class ScreenController {
NSLocalizedDescriptionKey: "web view unavailable",
])
}
return try await withCheckedThrowingContinuation { cont in
webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
}
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
let config = WKSnapshotConfiguration()
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
}
guard let webView = self.activeWebView else {
throw NSError(domain: "Screen", code: 3, userInfo: [
NSLocalizedDescriptionKey: "web view unavailable",
])
}
let image: UIImage = try await withCheckedThrowingContinuation { cont in
webView.takeSnapshot(with: config) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
NSLocalizedDescriptionKey: "snapshot failed",
]))
return
}
cont.resume(returning: image)
}
}
let image = try await self.snapshotImage(maxWidth: maxWidth)
guard let data = image.pngData() else {
throw NSError(domain: "Screen", code: 1, userInfo: [
NSLocalizedDescriptionKey: "snapshot encode failed",
@@ -190,30 +141,7 @@ final class ScreenController {
format: OpenClawCanvasSnapshotFormat,
quality: Double? = nil) async throws -> String
{
let config = WKSnapshotConfiguration()
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
}
guard let webView = self.activeWebView else {
throw NSError(domain: "Screen", code: 3, userInfo: [
NSLocalizedDescriptionKey: "web view unavailable",
])
}
let image: UIImage = try await withCheckedThrowingContinuation { cont in
webView.takeSnapshot(with: config) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
NSLocalizedDescriptionKey: "snapshot failed",
]))
return
}
cont.resume(returning: image)
}
}
let image = try await self.snapshotImage(maxWidth: maxWidth)
let data: Data?
switch format {
@@ -231,6 +159,34 @@ final class ScreenController {
return data.base64EncodedString()
}
private func snapshotImage(maxWidth: CGFloat?) async throws -> UIImage {
let config = WKSnapshotConfiguration()
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
}
guard let webView = self.activeWebView else {
throw NSError(domain: "Screen", code: 3, userInfo: [
NSLocalizedDescriptionKey: "web view unavailable",
])
}
let image: UIImage = try await withCheckedThrowingContinuation { cont in
webView.takeSnapshot(with: config) { image, error in
if let error {
cont.resume(throwing: error)
return
}
guard let image else {
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
NSLocalizedDescriptionKey: "snapshot failed",
]))
return
}
cont.resume(returning: image)
}
}
return image
}
func attachWebView(_ webView: WKWebView) {
self.activeWebView = webView
self.reload()
@@ -258,17 +214,6 @@ final class ScreenController {
ext: "html",
subdirectory: "CanvasScaffold")
private static func isLoopbackHost(_ host: String) -> Bool {
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if normalized.isEmpty { return true }
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
return true
}
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
return true
}
return false
}
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
let std = url.standardizedFileURL
@@ -290,59 +235,8 @@ final class ScreenController {
scrollView.bounces = allowScroll
}
private static func jsValue(_ value: String?) -> String {
guard let value else { return "null" }
if let data = try? JSONSerialization.data(withJSONObject: [value]),
let encoded = String(data: data, encoding: .utf8),
encoded.count >= 2
{
return String(encoded.dropFirst().dropLast())
}
return "null"
}
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
return false
}
if host == "localhost" { return true }
if host.hasSuffix(".local") { return true }
if host.hasSuffix(".ts.net") { return true }
if host.hasSuffix(".tailscale.net") { return true }
// Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
if !host.contains("."), !host.contains(":") { return true }
if let ipv4 = Self.parseIPv4(host) {
return Self.isLocalNetworkIPv4(ipv4)
}
return false
}
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
// 10.0.0.0/8
if a == 10 { return true }
// 172.16.0.0/12
if a == 172, (16...31).contains(Int(b)) { return true }
// 192.168.0.0/16
if a == 192, b == 168 { return true }
// 127.0.0.0/8
if a == 127 { return true }
// 169.254.0.0/16 (link-local)
if a == 169, b == 254 { return true }
// Tailscale: 100.64.0.0/10
if a == 100, (64...127).contains(Int(b)) { return true }
return false
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
}
nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? {

View File

@@ -84,8 +84,8 @@ final class ScreenRecordService: @unchecked Sendable {
throw ScreenRecordError.invalidScreenIndex(idx)
}
let durationMs = Self.clampDurationMs(durationMs)
let fps = Self.clampFps(fps)
let durationMs = CaptureRateLimits.clampDurationMs(durationMs)
let fps = CaptureRateLimits.clampFps(fps, maxFps: 30)
let fpsInt = Int32(fps.rounded())
let fpsValue = Double(fpsInt)
let includeAudio = includeAudio ?? true
@@ -319,16 +319,6 @@ final class ScreenRecordService: @unchecked Sendable {
}
}
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
let v = ms ?? 10000
return min(60000, max(250, v))
}
private nonisolated static func clampFps(_ fps: Double?) -> Double {
let v = fps ?? 10
if !v.isFinite { return 10 }
return min(30, max(1, v))
}
}
@MainActor
@@ -350,11 +340,11 @@ private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) ->
#if DEBUG
extension ScreenRecordService {
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
self.clampDurationMs(ms)
CaptureRateLimits.clampDurationMs(ms)
}
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
self.clampFps(fps)
CaptureRateLimits.clampFps(fps, maxFps: 30)
}
}
#endif

View File

@@ -0,0 +1,25 @@
import SwiftUI
extension View {
func gatewayActionsDialog(
isPresented: Binding<Bool>,
onDisconnect: @escaping () -> Void,
onOpenSettings: @escaping () -> Void) -> some View
{
self.confirmationDialog(
"Gateway",
isPresented: isPresented,
titleVisibility: .visible)
{
Button("Disconnect", role: .destructive) {
onDisconnect()
}
Button("Open Settings") {
onOpenSettings()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Disconnect from the gateway?")
}
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
enum GatewayStatusBuilder {
@MainActor
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
if appModel.gatewayServerName != nil { return .connected }
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
if text.localizedCaseInsensitiveContains("connecting") ||
text.localizedCaseInsensitiveContains("reconnecting")
{
return .connecting
}
if text.localizedCaseInsensitiveContains("error") {
return .error
}
return .disconnected
}
}

View File

@@ -0,0 +1,39 @@
import SwiftUI
private struct StatusGlassCardModifier: ViewModifier {
@Environment(\.colorSchemeContrast) private var contrast
let brighten: Bool
let verticalPadding: CGFloat
let horizontalPadding: CGFloat
func body(content: Content) -> some View {
content
.padding(.vertical, self.verticalPadding)
.padding(.horizontal, self.horizontalPadding)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5
)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
}
}
extension View {
func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View {
self.modifier(
StatusGlassCardModifier(
brighten: brighten,
verticalPadding: verticalPadding,
horizontalPadding: horizontalPadding
)
)
}
}

View File

@@ -3,7 +3,6 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
enum GatewayState: Equatable {
case connected
@@ -86,20 +85,7 @@ struct StatusPill: View {
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5
)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")

View File

@@ -1,8 +1,6 @@
import SwiftUI
struct VoiceWakeToast: View {
@Environment(\.colorSchemeContrast) private var contrast
var command: String
var brighten: Bool = false
@@ -18,20 +16,7 @@ struct VoiceWakeToast: View {
.lineLimit(1)
.truncationMode(.tail)
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
lineWidth: self.contrast == .increased ? 1.0 : 0.5
)
}
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
}
.statusGlassCard(brighten: self.brighten, verticalPadding: 10)
.accessibilityLabel("Voice Wake triggered")
.accessibilityValue("Command: \(self.command)")
}

View File

@@ -216,22 +216,7 @@ final class VoiceWakeManager: NSObject {
self.isEnabled = false
self.isListening = false
self.statusText = "Off"
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest = nil
if self.audioEngine.isRunning {
self.audioEngine.stop()
self.audioEngine.inputNode.removeTap(onBus: 0)
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
self.tearDownRecognitionPipeline()
}
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
@@ -241,22 +226,7 @@ final class VoiceWakeManager: NSObject {
self.isListening = false
self.statusText = "Paused"
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest = nil
if self.audioEngine.isRunning {
self.audioEngine.stop()
self.audioEngine.inputNode.removeTap(onBus: 0)
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
self.tearDownRecognitionPipeline()
return true
}
@@ -310,6 +280,24 @@ final class VoiceWakeManager: NSObject {
}
}
private func tearDownRecognitionPipeline() {
self.tapDrainTask?.cancel()
self.tapDrainTask = nil
self.tapQueue?.clear()
self.tapQueue = nil
self.recognitionTask?.cancel()
self.recognitionTask = nil
self.recognitionRequest = nil
if self.audioEngine.isRunning {
self.audioEngine.stop()
self.audioEngine.inputNode.removeTap(onBus: 0)
}
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
{ [weak self] result, error in
let transcript = result?.bestTranscription.formattedString
@@ -404,16 +392,10 @@ final class VoiceWakeManager: NSObject {
}
private nonisolated static func microphonePermissionMessage(kind: String) -> String {
switch AVAudioApplication.shared.recordPermission {
case .denied:
return "\(kind) permission denied"
case .undetermined:
return "\(kind) permission not granted"
case .granted:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
let status = AVAudioApplication.shared.recordPermission
return self.deniedByDefaultPermissionMessage(
kind: kind,
isUndetermined: status == .undetermined)
}
private nonisolated static func requestSpeechPermission() async -> Bool {
@@ -463,16 +445,7 @@ final class VoiceWakeManager: NSObject {
kind: String,
status: AVAudioSession.RecordPermission) -> String
{
switch status {
case .denied:
return "\(kind) permission denied"
case .undetermined:
return "\(kind) permission not granted"
case .granted:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
self.deniedByDefaultPermissionMessage(kind: kind, isUndetermined: status == .undetermined)
}
private static func permissionMessage(
@@ -492,6 +465,13 @@ final class VoiceWakeManager: NSObject {
return "\(kind) permission denied"
}
}
private static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
if isUndetermined {
return "\(kind) permission not granted"
}
return "\(kind) permission denied"
}
}
#if DEBUG

View File

@@ -2,6 +2,36 @@ import OpenClawKit
import Foundation
import Testing
private func setupCode(from payload: String) -> String {
Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func agentAction(
message: String,
sessionKey: String? = nil,
thinking: String? = nil,
deliver: Bool = false,
to: String? = nil,
channel: String? = nil,
timeoutSeconds: Int? = nil,
key: String? = nil) -> DeepLinkRoute
{
.agent(
.init(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: deliver,
to: to,
channel: channel,
timeoutSeconds: timeoutSeconds,
key: key))
}
@Suite struct DeepLinkParserTests {
@Test func parseRejectsUnknownHost() {
let url = URL(string: "openclaw://nope?message=hi")!
@@ -10,15 +40,7 @@ import Testing
@Test func parseHostIsCaseInsensitive() {
let url = URL(string: "openclaw://AGENT?message=Hello")!
#expect(DeepLinkParser.parse(url) == .agent(.init(
message: "Hello",
sessionKey: nil,
thinking: nil,
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: nil)))
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
}
@Test func parseRejectsNonOpenClawScheme() {
@@ -34,47 +56,29 @@ import Testing
@Test func parseAgentLinkParsesCommonFields() {
let url =
URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")!
#expect(
DeepLinkParser.parse(url) == .agent(
.init(
message: "Hello",
sessionKey: "node-test",
thinking: "low",
deliver: true,
to: nil,
channel: nil,
timeoutSeconds: 30,
key: nil)))
#expect(DeepLinkParser.parse(url) == agentAction(
message: "Hello",
sessionKey: "node-test",
thinking: "low",
deliver: true,
timeoutSeconds: 30))
}
@Test func parseAgentLinkParsesTargetRoutingFields() {
let url =
URL(
string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
#expect(
DeepLinkParser.parse(url) == .agent(
.init(
message: "Hello World",
sessionKey: nil,
thinking: nil,
deliver: true,
to: "+15551234567",
channel: "whatsapp",
timeoutSeconds: nil,
key: "secret")))
#expect(DeepLinkParser.parse(url) == agentAction(
message: "Hello World",
deliver: true,
to: "+15551234567",
channel: "whatsapp",
key: "secret"))
}
@Test func parseRejectsNegativeTimeoutSeconds() {
let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")!
#expect(DeepLinkParser.parse(url) == .agent(.init(
message: "Hello",
sessionKey: nil,
thinking: nil,
deliver: false,
to: nil,
channel: nil,
timeoutSeconds: nil,
key: nil)))
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
}
@Test func parseGatewayLinkParsesCommonFields() {
@@ -99,13 +103,7 @@ import Testing
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
@@ -121,13 +119,7 @@ import Testing
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
@@ -139,37 +131,19 @@ import Testing
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
let encoded = Data(payload.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "127.0.0.1",

View File

@@ -4,31 +4,6 @@ import Testing
import UIKit
@testable import OpenClaw
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard

View File

@@ -5,6 +5,32 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
private func makeController() -> GatewayConnectionController {
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
}
private func makeDiscoveredGateway(
stableID: String,
lanHost: String?,
tailnetDns: String?,
gatewayPort: Int?,
fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
{
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
return GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
stableID: stableID,
debugID: "debug",
lanHost: lanHost,
tailnetDns: tailnetDns,
gatewayPort: gatewayPort,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: fingerprint,
cliPath: nil)
}
private func clearTLSFingerprint(stableID: String) {
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
suite.removeObject(forKey: "gateway.tls.\(stableID)")
@@ -17,22 +43,13 @@ import Testing
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
let gateway = makeDiscoveredGateway(
stableID: stableID,
debugID: "debug",
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: "22",
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
fingerprint: "22")
let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == "11")
@@ -44,22 +61,13 @@ import Testing
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
let gateway = makeDiscoveredGateway(
stableID: stableID,
debugID: "debug",
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: "22",
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
fingerprint: "22")
let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == nil)
@@ -82,22 +90,13 @@ import Testing
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
name: "Test",
endpoint: endpoint,
let gateway = makeDiscoveredGateway(
stableID: stableID,
debugID: "debug",
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
canvasPort: nil,
tlsEnabled: true,
tlsFingerprintSha256: nil,
cliPath: nil)
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
fingerprint: nil)
let controller = makeController()
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
@@ -105,8 +104,7 @@ import Testing
}
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true)
@@ -121,8 +119,7 @@ import Testing
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
let appModel = NodeAppModel()
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
let controller = makeController()
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)

View File

@@ -14,6 +14,19 @@ private let instanceIdEntry = KeychainEntry(service: nodeService, account: "inst
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
private let bootstrapDefaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
private let bootstrapKeychainEntries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
private let lastGatewayDefaultsKeys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard
@@ -61,142 +74,112 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot)
}
private func withBootstrapSnapshots(_ body: () -> Void) {
let defaultsSnapshot = snapshotDefaults(bootstrapDefaultsKeys)
let keychainSnapshot = snapshotKeychain(bootstrapKeychainEntries)
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
}
body()
}
private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
let snapshot = snapshotDefaults(lastGatewayDefaultsKeys)
defer { restoreDefaults(snapshot) }
body()
}
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
let defaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries)
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": "node-test",
"gateway.preferredStableID": "preferred-test",
"gateway.lastDiscoveredStableID": "last-test",
])
applyKeychain([
instanceIdEntry: nil,
preferredGatewayEntry: nil,
lastGatewayEntry: nil,
])
GatewaySettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
applyDefaults([
"node.instanceId": "node-test",
"gateway.preferredStableID": "preferred-test",
"gateway.lastDiscoveredStableID": "last-test",
])
applyKeychain([
instanceIdEntry: nil,
preferredGatewayEntry: nil,
lastGatewayEntry: nil,
])
GatewaySettingsStore.bootstrapPersistence()
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
let defaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
"gateway.lastDiscoveredStableID",
]
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
let keychainSnapshot = snapshotKeychain(entries)
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
withBootstrapSnapshots {
applyDefaults([
"node.instanceId": nil,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
])
applyKeychain([
instanceIdEntry: "node-from-keychain",
preferredGatewayEntry: "preferred-from-keychain",
lastGatewayEntry: "last-from-keychain",
])
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
applyDefaults([
"node.instanceId": nil,
"gateway.preferredStableID": nil,
"gateway.lastDiscoveredStableID": nil,
])
applyKeychain([
instanceIdEntry: "node-from-keychain",
preferredGatewayEntry: "preferred-from-keychain",
lastGatewayEntry: "last-from-keychain",
])
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
@Test func lastGateway_manualRoundTrip() {
let keys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
let snapshot = snapshotDefaults(keys)
defer { restoreDefaults(snapshot) }
withLastGatewayDefaultsSnapshot {
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "example.com",
port: 443,
useTLS: true,
stableID: "manual|example.com|443")
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "example.com",
port: 443,
useTLS: true,
stableID: "manual|example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
}
}
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
let keys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
let snapshot = snapshotDefaults(keys)
defer { restoreDefaults(snapshot) }
withLastGatewayDefaultsSnapshot {
// Simulate a prior manual record that included host/port.
applyDefaults([
"gateway.last.host": "10.0.0.99",
"gateway.last.port": 18789,
"gateway.last.tls": true,
"gateway.last.stableID": "manual|10.0.0.99|18789",
"gateway.last.kind": "manual",
])
// Simulate a prior manual record that included host/port.
applyDefaults([
"gateway.last.host": "10.0.0.99",
"gateway.last.port": 18789,
"gateway.last.tls": true,
"gateway.last.stableID": "manual|10.0.0.99|18789",
"gateway.last.kind": "manual",
])
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.host") == nil)
#expect(defaults.object(forKey: "gateway.last.port") == nil)
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.host") == nil)
#expect(defaults.object(forKey: "gateway.last.port") == nil)
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
}
}
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
let keys = [
"gateway.last.kind",
"gateway.last.host",
"gateway.last.port",
"gateway.last.tls",
"gateway.last.stableID",
]
let snapshot = snapshotDefaults(keys)
defer { restoreDefaults(snapshot) }
withLastGatewayDefaultsSnapshot {
applyDefaults([
"gateway.last.kind": nil,
"gateway.last.host": "example.org",
"gateway.last.port": 18789,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|example.org|18789",
])
applyDefaults([
"gateway.last.kind": nil,
"gateway.last.host": "example.org",
"gateway.last.port": 18789,
"gateway.last.tls": false,
"gateway.last.stableID": "manual|example.org|18789",
])
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
}
}
@Test func talkProviderApiKey_genericRoundTrip() {

View File

@@ -4,31 +4,6 @@ import Testing
import UIKit
@testable import OpenClaw
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}
private func makeAgentDeepLinkURL(
message: String,
deliver: Bool = false,

View File

@@ -0,0 +1,26 @@
import Foundation
func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
let defaults = UserDefaults.standard
var snapshot: [String: Any?] = [:]
for key in updates.keys {
snapshot[key] = defaults.object(forKey: key)
}
for (key, value) in updates {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
defer {
for (key, value) in snapshot {
if let value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
return try body()
}

View File

@@ -3,6 +3,19 @@ import SwabbleKit
import Testing
@testable import OpenClaw
private let openclawTranscript = "hey openclaw do thing"
private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegment] {
makeSegments(
transcript: openclawTranscript,
words: [
("hey", 0.0, 0.1),
("openclaw", 0.2, 0.1),
("do", postTriggerStart, 0.1),
("thing", postTriggerStart + 0.2, 0.1),
])
}
@Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
let transcript = "hello world"
@@ -13,17 +26,9 @@ import Testing
}
@Test func extractCommandTrimsTokensAndResult() {
let transcript = "hey openclaw do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("openclaw", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let segments = openclawSegments(postTriggerStart: 0.9)
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
from: openclawTranscript,
segments: segments,
triggers: [" openclaw "],
minPostTriggerGap: 0.3)
@@ -31,17 +36,9 @@ import Testing
}
@Test func extractCommandReturnsNilWhenGapTooShort() {
let transcript = "hey openclaw do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("openclaw", 0.2, 0.1),
("do", 0.35, 0.1),
("thing", 0.5, 0.1),
])
let segments = openclawSegments(postTriggerStart: 0.35)
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
from: openclawTranscript,
segments: segments,
triggers: ["openclaw"],
minPostTriggerGap: 0.3)
@@ -57,17 +54,9 @@ import Testing
}
@Test func extractCommandIgnoresEmptyTriggers() {
let transcript = "hey openclaw do thing"
let segments = makeSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
("openclaw", 0.2, 0.1),
("do", 0.9, 0.1),
("thing", 1.1, 0.1),
])
let segments = openclawSegments(postTriggerStart: 0.9)
let cmd = VoiceWakeManager.extractCommand(
from: transcript,
from: openclawTranscript,
segments: segments,
triggers: ["", " ", "openclaw"],
minPostTriggerGap: 0.3)

View File

@@ -0,0 +1,30 @@
import Foundation
enum AgentWorkspaceConfig {
static func workspace(from root: [String: Any]) -> String? {
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
}
static func setWorkspace(in root: inout [String: Any], workspace: String?) {
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace")
} else {
defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
}
}

View File

@@ -9,21 +9,7 @@ final class AudioInputDeviceObserver {
private var defaultInputListener: AudioObjectPropertyListenerBlock?
static func defaultInputDeviceUID() -> String? {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var deviceID = AudioObjectID(0)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
let status = AudioObjectGetPropertyData(
systemObject,
&address,
0,
nil,
&size,
&deviceID)
guard status == noErr, deviceID != 0 else { return nil }
guard let deviceID = self.defaultInputDeviceID() else { return nil }
return self.deviceUID(for: deviceID)
}
@@ -63,6 +49,15 @@ final class AudioInputDeviceObserver {
}
static func defaultInputDeviceSummary() -> String {
guard let deviceID = self.defaultInputDeviceID() else {
return "defaultInput=unknown"
}
let uid = self.deviceUID(for: deviceID) ?? "unknown"
let name = self.deviceName(for: deviceID) ?? "unknown"
return "defaultInput=\(name) (\(uid))"
}
private static func defaultInputDeviceID() -> AudioObjectID? {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
@@ -77,12 +72,8 @@ final class AudioInputDeviceObserver {
nil,
&size,
&deviceID)
guard status == noErr, deviceID != 0 else {
return "defaultInput=unknown"
}
let uid = self.deviceUID(for: deviceID) ?? "unknown"
let name = self.deviceName(for: deviceID) ?? "unknown"
return "defaultInput=\(name) (\(uid))"
guard status == noErr, deviceID != 0 else { return nil }
return deviceID
}
func start(onChange: @escaping @Sendable () -> Void) {

View File

@@ -64,45 +64,33 @@ actor CameraCaptureService {
try await self.ensureAccess(for: .video)
let session = AVCaptureSession()
session.sessionPreset = .photo
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
throw CameraError.cameraUnavailable
}
let input = try AVCaptureDeviceInput(device: device)
guard session.canAddInput(input) else {
throw CameraError.captureFailed("Failed to add camera input")
}
session.addInput(input)
let output = AVCapturePhotoOutput()
guard session.canAddOutput(output) else {
throw CameraError.captureFailed("Failed to add photo output")
}
session.addOutput(output)
output.maxPhotoQualityPrioritization = .quality
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
preferFrontCamera: facing == .front,
deviceId: deviceId,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: { setupError in
CameraError.captureFailed(setupError.localizedDescription)
})
let session = prepared.session
let device = prepared.device
let output = prepared.output
session.startRunning()
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
await CameraCapturePipelineSupport.warmUpCaptureSession()
await self.waitForExposureAndWhiteBalance(device: device)
await self.sleepDelayMs(delayMs)
let settings: AVCapturePhotoSettings = {
if output.availablePhotoCodecTypes.contains(.jpeg) {
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
}
return AVCapturePhotoSettings()
}()
settings.photoQualityPrioritization = .quality
var delegate: PhotoCaptureDelegate?
let rawData: Data = try await withCheckedThrowingContinuation { cont in
let d = PhotoCaptureDelegate(cont)
delegate = d
output.capturePhoto(with: settings, delegate: d)
let rawData: Data = try await withCheckedThrowingContinuation { continuation in
let captureDelegate = PhotoCaptureDelegate(continuation)
delegate = captureDelegate
output.capturePhoto(
with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
delegate: captureDelegate)
}
withExtendedLifetime(delegate) {}
@@ -135,39 +123,19 @@ actor CameraCaptureService {
try await self.ensureAccess(for: .audio)
}
let session = AVCaptureSession()
session.sessionPreset = .high
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
throw CameraError.cameraUnavailable
}
let cameraInput = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(cameraInput) else {
throw CameraError.captureFailed("Failed to add camera input")
}
session.addInput(cameraInput)
if includeAudio {
guard let mic = AVCaptureDevice.default(for: .audio) else {
throw CameraError.microphoneUnavailable
}
let micInput = try AVCaptureDeviceInput(device: mic)
guard session.canAddInput(micInput) else {
throw CameraError.captureFailed("Failed to add microphone input")
}
session.addInput(micInput)
}
let output = AVCaptureMovieFileOutput()
guard session.canAddOutput(output) else {
throw CameraError.captureFailed("Failed to add movie output")
}
session.addOutput(output)
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
session.startRunning()
let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
preferFrontCamera: facing == .front,
deviceId: deviceId,
includeAudio: includeAudio,
durationMs: durationMs,
pickCamera: { preferFrontCamera, deviceId in
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
},
cameraUnavailableError: CameraError.cameraUnavailable,
mapSetupError: Self.mapMovieSetupError)
let session = prepared.session
let output = prepared.output
defer { session.stopRunning() }
await Self.warmUpCaptureSession()
let tmpMovURL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
@@ -180,7 +148,6 @@ actor CameraCaptureService {
return FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
}()
// Ensure we don't fail exporting due to an existing file.
try? FileManager().removeItem(at: outputURL)
@@ -192,28 +159,12 @@ actor CameraCaptureService {
output.startRecording(to: tmpMovURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
switch status {
case .authorized:
return
case .notDetermined:
let ok = await withCheckedContinuation(isolation: nil) { cont in
AVCaptureDevice.requestAccess(for: mediaType) { granted in
cont.resume(returning: granted)
}
}
if !ok {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
case .denied, .restricted:
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
@unknown default:
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
@@ -278,6 +229,13 @@ actor CameraCaptureService {
return min(60000, max(250, v))
}
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
CameraCapturePipelineSupport.mapMovieSetupError(
setupError,
microphoneUnavailableError: .microphoneUnavailable,
captureFailed: { .captureFailed($0) })
}
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
@@ -315,11 +273,6 @@ actor CameraCaptureService {
}
}
private nonisolated static func warmUpCaptureSession() async {
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
}
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
let stepNs: UInt64 = 50_000_000
let maxSteps = 30 // ~1.5s
@@ -338,11 +291,7 @@ actor CameraCaptureService {
}
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
switch position {
case .front: "front"
case .back: "back"
default: "unspecified"
}
CameraCapturePipelineSupport.positionLabel(position)
}
}

View File

@@ -109,40 +109,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
}
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
return false
}
if host == "localhost" { return true }
if host.hasSuffix(".local") { return true }
if host.hasSuffix(".ts.net") { return true }
if host.hasSuffix(".tailscale.net") { return true }
if !host.contains("."), !host.contains(":") { return true }
if let ipv4 = Self.parseIPv4(host) {
return Self.isLocalNetworkIPv4(ipv4)
}
return false
}
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return nil }
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
guard bytes.count == 4 else { return nil }
return (bytes[0], bytes[1], bytes[2], bytes[3])
}
static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
let (a, b, _, _) = ip
if a == 10 { return true }
if a == 172, (16...31).contains(Int(b)) { return true }
if a == 192, b == 168 { return true }
if a == 127 { return true }
if a == 169, b == 254 { return true }
if a == 100, (64...127).contains(Int(b)) { return true }
return false
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
}
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).

View File

@@ -1,24 +1,13 @@
import Foundation
final class CanvasFileWatcher: @unchecked Sendable {
private let watcher: CoalescingFSEventsWatcher
final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
let watcher: SimpleFileWatcher
init(url: URL, onChange: @escaping () -> Void) {
self.watcher = CoalescingFSEventsWatcher(
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
paths: [url.path],
queueLabel: "ai.openclaw.canvaswatcher",
onChange: onChange)
onChange: onChange))
}
deinit {
self.stop()
}
func start() {
self.watcher.start()
}
func stop() {
self.watcher.stop()
}
}

View File

@@ -25,11 +25,11 @@ extension CanvasWindowController {
}
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
CanvasA2UIActionMessageHandler.parseIPv4(host)
LoopbackHost.parseIPv4(host)
}
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
LoopbackHost.isLocalNetworkIPv4(ip)
}
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {

View File

@@ -274,25 +274,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
func applyDebugStatusIfNeeded() {
let enabled = self.debugStatusEnabled
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
let js = """
(() => {
try {
const api = globalThis.__openclaw;
if (!api) return;
if (typeof api.setDebugStatusEnabled === 'function') {
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
}
if (!\(enabled ? "true" : "false")) return;
if (typeof api.setStatus === 'function') {
api.setStatus(\(title), \(subtitle));
}
} catch (_) {}
})();
"""
self.webView.evaluateJavaScript(js) { _, _ in }
WebViewJavaScriptSupport.applyDebugStatus(
webView: self.webView,
enabled: self.debugStatusEnabled,
title: self.debugStatusTitle,
subtitle: self.debugStatusSubtitle)
}
private func loadFile(_ url: URL) {
@@ -302,19 +288,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
func eval(javaScript: String) async throws -> String {
try await withCheckedThrowingContinuation { cont in
self.webView.evaluateJavaScript(javaScript) { result, error in
if let error {
cont.resume(throwing: error)
return
}
if let result {
cont.resume(returning: String(describing: result))
} else {
cont.resume(returning: "")
}
}
}
try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript)
}
func snapshot(to outPath: String?) async throws -> String {

View File

@@ -9,6 +9,90 @@ extension ChannelsSettings {
self.store.snapshot?.decodeChannel(id, as: type)
}
private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
if !configured { return .secondary }
if hasError { return .orange }
if probeOk == false { return .orange }
if running { return .green }
return .orange
}
private func configuredChannelSummary(configured: Bool, running: Bool) -> String {
if !configured { return "Not configured" }
if running { return "Running" }
return "Configured"
}
private func appendProbeDetails(
lines: inout [String],
probeOk: Bool?,
probeStatus: Int?,
probeElapsedMs: Double?,
probeVersion: String? = nil,
probeError: String? = nil,
lastProbeAtMs: Double?,
lastError: String?)
{
if let probeOk {
if probeOk {
if let version = probeVersion, !version.isEmpty {
lines.append("Version \(version)")
}
if let elapsed = probeElapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else if let probeError, !probeError.isEmpty {
lines.append("Probe error: \(probeError)")
} else {
let code = probeStatus.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: lastProbeAtMs) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let lastError, !lastError.isEmpty {
lines.append("Error: \(lastError)")
}
}
private func finishDetails(
lines: inout [String],
probeOk: Bool?,
probeStatus: Int?,
probeElapsedMs: Double?,
probeVersion: String? = nil,
probeError: String? = nil,
lastProbeAtMs: Double?,
lastError: String?) -> String?
{
self.appendProbeDetails(
lines: &lines,
probeOk: probeOk,
probeStatus: probeStatus,
probeElapsedMs: probeElapsedMs,
probeVersion: probeVersion,
probeError: probeError,
lastProbeAtMs: lastProbeAtMs,
lastError: lastError)
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
private func finishProbeDetails(
lines: inout [String],
probe: (ok: Bool?, status: Int?, elapsedMs: Double?),
lastProbeAtMs: Double?,
lastError: String?) -> String?
{
self.finishDetails(
lines: &lines,
probeOk: probe.ok,
probeStatus: probe.status,
probeElapsedMs: probe.elapsedMs,
lastProbeAtMs: lastProbeAtMs,
lastError: lastError)
}
var whatsAppTint: Color {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return .secondary }
@@ -23,51 +107,51 @@ extension ChannelsSettings {
var telegramTint: Color {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var discordTint: Color {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var googlechatTint: Color {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var imessageTint: Color {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
return self.configuredChannelTint(
configured: status.configured,
running: status.running,
hasError: status.lastError != nil,
probeOk: status.probe?.ok)
}
var whatsAppSummary: String {
@@ -82,41 +166,31 @@ extension ChannelsSettings {
var telegramSummary: String {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var discordSummary: String {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var googlechatSummary: String {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var imessageSummary: String {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var whatsAppDetails: String? {
@@ -168,18 +242,15 @@ extension ChannelsSettings {
if let url = probe.webhook?.url, !url.isEmpty {
lines.append("Webhook: \(url)")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
return self.finishDetails(
lines: &lines,
probeOk: status.probe?.ok,
probeStatus: status.probe?.status,
probeElapsedMs: nil,
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var discordDetails: String? {
@@ -189,26 +260,17 @@ extension ChannelsSettings {
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
if let name = status.probe?.bot?.username, !name.isEmpty {
lines.append("Bot: @\(name)")
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
return self.finishProbeDetails(
lines: &lines,
probe: (
ok: status.probe?.ok,
status: status.probe?.status,
elapsedMs: status.probe?.elapsedMs),
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var googlechatDetails: String? {
@@ -223,23 +285,14 @@ extension ChannelsSettings {
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
lines.append("Audience: \(label)")
}
if let probe = status.probe {
if probe.ok {
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
return self.finishProbeDetails(
lines: &lines,
probe: (
ok: status.probe?.ok,
status: status.probe?.status,
elapsedMs: status.probe?.elapsedMs),
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var signalDetails: String? {
@@ -247,26 +300,14 @@ extension ChannelsSettings {
else { return nil }
var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)")
if let probe = status.probe {
if probe.ok {
if let version = probe.version, !version.isEmpty {
lines.append("Version \(version)")
}
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
return self.finishDetails(
lines: &lines,
probeOk: status.probe?.ok,
probeStatus: status.probe?.status,
probeElapsedMs: status.probe?.elapsedMs,
probeVersion: status.probe?.version,
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var imessageDetails: String? {
@@ -279,17 +320,14 @@ extension ChannelsSettings {
if let dbPath = status.dbPath, !dbPath.isEmpty {
lines.append("DB: \(dbPath)")
}
if let probe = status.probe, !probe.ok {
let err = probe.error ?? "probe failed"
lines.append("Probe error: \(err)")
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
return self.finishDetails(
lines: &lines,
probeOk: status.probe?.ok,
probeStatus: nil,
probeElapsedMs: nil,
probeError: status.probe?.error,
lastProbeAtMs: status.lastProbeAt,
lastError: status.lastError)
}
var orderedChannels: [ChannelItem] {

View File

@@ -18,7 +18,7 @@ extension ChannelsSettings {
}
private var sidebar: some View {
ScrollView {
SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledChannels.isEmpty {
self.sidebarSectionHeader("Configured")
@@ -34,14 +34,7 @@ extension ChannelsSettings {
}
}
}
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {

View File

@@ -0,0 +1,14 @@
import SwiftUI
enum ColorHexSupport {
static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
}

View File

@@ -1,11 +1,11 @@
import Foundation
final class ConfigFileWatcher: @unchecked Sendable {
final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
private let url: URL
private let watchedDir: URL
private let targetPath: String
private let targetName: String
private let watcher: CoalescingFSEventsWatcher
let watcher: SimpleFileWatcher
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
@@ -15,7 +15,7 @@ final class ConfigFileWatcher: @unchecked Sendable {
let watchedDirPath = self.watchedDir.path
let targetPath = self.targetPath
let targetName = self.targetName
self.watcher = CoalescingFSEventsWatcher(
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
paths: [watchedDirPath],
queueLabel: "ai.openclaw.configwatcher",
shouldNotify: { _, eventPaths in
@@ -28,18 +28,7 @@ final class ConfigFileWatcher: @unchecked Sendable {
}
return false
},
onChange: onChange)
onChange: onChange))
}
deinit {
self.stop()
}
func start() {
self.watcher.start()
}
func stop() {
self.watcher.stop()
}
}

View File

@@ -72,7 +72,7 @@ extension ConfigSettings {
}
private var sidebar: some View {
ScrollView {
SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
@@ -86,14 +86,7 @@ extension ConfigSettings {
}
}
}
.padding(.vertical, 10)
.padding(.horizontal, 10)
}
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor)))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {

View File

@@ -6,10 +6,6 @@ struct ContextMenuCardView: View {
private let rows: [SessionRow]
private let statusText: String?
private let isLoading: Bool
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 8
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
private let barHeight: CGFloat = 3
init(
@@ -23,45 +19,32 @@ struct ContextMenuCardView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
} else if self.rows.isEmpty, !self.isLoading {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 12) {
if self.rows.isEmpty, self.isLoading {
ForEach(0..<2, id: \.self) { _ in
self.placeholderRow
}
} else {
ForEach(self.rows) { row in
self.sessionRow(row)
MenuHeaderCard(
title: "Context",
subtitle: self.subtitle,
statusText: self.statusText,
paddingBottom: 8)
{
if self.statusText == nil {
if self.rows.isEmpty, !self.isLoading {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 12) {
if self.rows.isEmpty, self.isLoading {
ForEach(0..<2, id: \.self) { _ in
self.placeholderRow
}
} else {
ForEach(self.rows) { row in
self.sessionRow(row)
}
}
}
}
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
private var subtitle: String {

View File

@@ -336,16 +336,8 @@ final class ControlChannel {
}
private func startEventStream() {
self.eventTask?.cancel()
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handle(push: push)
}
}
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
self?.handle(push: push)
}
}

View File

@@ -258,14 +258,6 @@ extension CronJobEditor {
}
func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
DurationFormattingSupport.conciseDuration(ms: ms)
}
}

View File

@@ -38,7 +38,9 @@ final class CronJobsStore {
func start() {
guard !self.isPreview else { return }
guard self.eventTask == nil else { return }
self.startGatewaySubscription()
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
self?.handle(push: push)
}
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refreshJobs()
@@ -142,20 +144,6 @@ final class CronJobsStore {
// MARK: - Gateway events
private func startGatewaySubscription() {
self.eventTask?.cancel()
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handle(push: push)
}
}
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "cron":

View File

@@ -31,15 +31,7 @@ extension CronSettings {
}
func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
DurationFormattingSupport.conciseDuration(ms: ms)
}
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {

View File

@@ -17,9 +17,7 @@ final class DevicePairingApprovalPrompter {
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
private var activeAlert: NSAlert?
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private let alertState = PairingAlertState()
private var resolvedByRequestId: Set<String> = []
private struct PairingList: Codable {
@@ -55,48 +53,35 @@ final class DevicePairingApprovalPrompter {
}
}
private struct PairingResolvedEvent: Codable {
let requestId: String
let deviceId: String
let decision: String
let ts: Double
}
private enum PairingResolution: String {
case approved
case rejected
}
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
func start() {
guard self.task == nil else { return }
self.isStopping = false
self.task = Task { [weak self] in
guard let self else { return }
_ = try? await GatewayConnection.shared.refresh()
await self.loadPendingRequestsFromGateway()
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in self?.handle(push: push) }
}
}
self.startPushTask()
}
private func startPushTask() {
PairingAlertSupport.startPairingPushTask(
task: &self.task,
isStopping: &self.isStopping,
loadPending: self.loadPendingRequestsFromGateway,
handlePush: self.handle(push:))
}
func stop() {
self.isStopping = true
self.endActiveAlert()
self.task?.cancel()
self.task = nil
self.queue.removeAll(keepingCapacity: false)
self.stopPushTask()
self.updatePendingCounts()
self.isPresenting = false
self.activeRequestId = nil
self.alertHostWindow?.orderOut(nil)
self.alertHostWindow?.close()
self.alertHostWindow = nil
self.resolvedByRequestId.removeAll(keepingCapacity: false)
}
private func stopPushTask() {
PairingAlertSupport.stopPairingPrompter(
isStopping: &self.isStopping,
task: &self.task,
queue: &self.queue,
isPresenting: &self.isPresenting,
state: self.alertState)
}
private func loadPendingRequestsFromGateway() async {
do {
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
@@ -127,44 +112,13 @@ final class DevicePairingApprovalPrompter {
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow device to connect?"
alert.informativeText = Self.describe(req)
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
self.activeAlert = alert
self.activeRequestId = req.requestId
let hostWindow = self.requireAlertHostWindow()
let sheetSize = alert.window.frame.size
if let screen = hostWindow.screen ?? NSScreen.main {
let bounds = screen.visibleFrame
let x = bounds.midX - (sheetSize.width / 2)
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
} else {
hostWindow.center()
}
hostWindow.makeKeyAndOrderFront(nil)
alert.beginSheetModal(for: hostWindow) { [weak self] response in
Task { @MainActor [weak self] in
guard let self else { return }
self.activeRequestId = nil
self.activeAlert = nil
await self.handleAlertResponse(response, request: req)
hostWindow.orderOut(nil)
}
}
PairingAlertSupport.presentPairingAlert(
request: req,
requestId: req.requestId,
messageText: "Allow device to connect?",
informativeText: Self.describe(req),
state: self.alertState,
onResponse: self.handleAlertResponse)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
@@ -206,33 +160,27 @@ final class DevicePairingApprovalPrompter {
}
private func approve(requestId: String) async -> Bool {
do {
await PairingAlertSupport.approveRequest(
requestId: requestId,
kind: "device",
logger: self.logger)
{
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)")
return true
} catch {
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
private func reject(requestId: String) async {
do {
await PairingAlertSupport.rejectRequest(
requestId: requestId,
kind: "device",
logger: self.logger)
{
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
private func endActiveAlert() {
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
}
private func requireAlertHostWindow() -> NSWindow {
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
@@ -269,9 +217,10 @@ final class DevicePairingApprovalPrompter {
}
private func handleResolved(_ resolved: PairingResolvedEvent) {
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution
.approved : .rejected
if let activeRequestId, activeRequestId == resolved.requestId {
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
? PairingAlertSupport.PairingResolution.approved
: PairingAlertSupport.PairingResolution.rejected
if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
self.resolvedByRequestId.insert(resolved.requestId)
self.endActiveAlert()
let decision = resolution.rawValue

View File

@@ -0,0 +1,15 @@
import Foundation
enum DurationFormattingSupport {
static func conciseDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
}

View File

@@ -19,15 +19,13 @@ final class ExecApprovalsGatewayPrompter {
}
func start() {
guard self.task == nil else { return }
self.task = Task { [weak self] in
SimpleTaskSupport.start(task: &self.task) { [weak self] in
await self?.run()
}
}
func stop() {
self.task?.cancel()
self.task = nil
SimpleTaskSupport.stop(task: &self.task)
}
private func run() async {

View File

@@ -73,6 +73,22 @@ private struct ExecHostResponse: Codable {
var error: ExecHostError?
}
private func readLineFromHandle(_ handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError {
var message: String
@@ -159,28 +175,12 @@ enum ExecApprovalsSocketClient {
payload.append(0x0A)
try handle.write(contentsOf: payload)
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
let lineData = line.data(using: .utf8)
else { return nil }
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
return response.decision
}
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
}
@MainActor
@@ -781,7 +781,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
return
}
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
let data = line.data(using: .utf8)
else {
return
@@ -815,22 +815,6 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
}
}
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
private func sendApprovalResponse(
handle: FileHandle,
id: String,

View File

@@ -12,19 +12,6 @@ enum ExecCommandToken {
enum ExecEnvInvocationUnwrapper {
static let maxWrapperDepth = 4
private static let optionsWithValue = Set([
"-u",
"--unset",
"-c",
"--chdir",
"-s",
"--split-string",
"--default-signal",
"--ignore-signal",
"--block-signal",
])
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
private static func isEnvAssignment(_ token: String) -> Bool {
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
return token.range(of: pattern, options: .regularExpression) != nil
@@ -55,11 +42,11 @@ enum ExecEnvInvocationUnwrapper {
if token.hasPrefix("-"), token != "-" {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if self.flagOptions.contains(flag) {
if ExecEnvOptions.flagOnly.contains(flag) {
idx += 1
continue
}
if self.optionsWithValue.contains(flag) {
if ExecEnvOptions.withValue.contains(flag) {
if !lower.contains("=") {
expectsOptionValue = true
}

View File

@@ -0,0 +1,29 @@
import Foundation
enum ExecEnvOptions {
static let withValue = Set([
"-u",
"--unset",
"-c",
"--chdir",
"-s",
"--split-string",
"--default-signal",
"--ignore-signal",
"--block-signal",
])
static let flagOnly = Set(["-i", "--ignore-environment", "-0", "--null"])
static let inlineValuePrefixes = [
"-u",
"-c",
"-s",
"--unset=",
"--chdir=",
"--split-string=",
"--default-signal=",
"--ignore-signal=",
"--block-signal=",
]
}

View File

@@ -39,30 +39,6 @@ enum ExecSystemRunCommandValidator {
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"])
private static let envOptionsWithValue = Set([
"-u",
"--unset",
"-c",
"--chdir",
"-s",
"--split-string",
"--default-signal",
"--ignore-signal",
"--block-signal",
])
private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
private static let envInlineValuePrefixes = [
"-u",
"-c",
"-s",
"--unset=",
"--chdir=",
"--split-string=",
"--default-signal=",
"--ignore-signal=",
"--block-signal=",
]
private struct EnvUnwrapResult {
let argv: [String]
let usesModifiers: Bool
@@ -113,7 +89,7 @@ enum ExecSystemRunCommandValidator {
}
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
}
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
@@ -148,12 +124,12 @@ enum ExecSystemRunCommandValidator {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if self.envFlagOptions.contains(flag) {
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if self.envOptionsWithValue.contains(flag) {
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
@@ -301,10 +277,15 @@ enum ExecSystemRunCommandValidator {
return current
}
private static func resolveInlineCommandTokenIndex(
private struct InlineCommandTokenMatch {
var tokenIndex: Int
var inlineCommand: String?
}
private static func findInlineCommandTokenMatch(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> Int?
allowCombinedC: Bool) -> InlineCommandTokenMatch?
{
var idx = 1
while idx < argv.count {
@@ -318,21 +299,35 @@ enum ExecSystemRunCommandValidator {
break
}
if flags.contains(lower) {
return idx + 1 < argv.count ? idx + 1 : nil
return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil)
}
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
let inline = String(token.dropFirst(inlineOffset))
.trimmingCharacters(in: .whitespacesAndNewlines)
if !inline.isEmpty {
return idx
}
return idx + 1 < argv.count ? idx + 1 : nil
return InlineCommandTokenMatch(
tokenIndex: idx,
inlineCommand: inline.isEmpty ? nil : inline)
}
idx += 1
}
return nil
}
private static func resolveInlineCommandTokenIndex(
_ argv: [String],
flags: Set<String>,
allowCombinedC: Bool) -> Int?
{
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
return nil
}
if match.inlineCommand != nil {
return match.tokenIndex
}
let nextIndex = match.tokenIndex + 1
return nextIndex < argv.count ? nextIndex : nil
}
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
let chars = Array(token.lowercased())
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
@@ -371,30 +366,14 @@ enum ExecSystemRunCommandValidator {
flags: Set<String>,
allowCombinedC: Bool) -> String?
{
var idx = 1
while idx < argv.count {
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
idx += 1
continue
}
let lower = token.lowercased()
if lower == "--" {
break
}
if flags.contains(lower) {
return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
}
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
let inline = String(token.dropFirst(inlineOffset))
if let inlineValue = self.trimmedNonEmpty(inline) {
return inlineValue
}
return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
}
idx += 1
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
return nil
}
return nil
if let inlineCommand = match.inlineCommand {
return inlineCommand
}
let nextIndex = match.tokenIndex + 1
return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
}
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {

View File

@@ -48,27 +48,11 @@ struct GatewayDiscoveryInlineList: View {
.truncationMode(.middle)
}
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
SelectionStateIndicator(selected: selected)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(self.rowBackground(
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
.openClawSelectableRowChrome(
selected: selected,
hovered: self.hoveredGatewayID == gateway.id)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -106,12 +90,6 @@ struct GatewayDiscoveryInlineList: View {
}
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
if selected { return Color.accentColor.opacity(0.12) }
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}

View File

@@ -0,0 +1,22 @@
import OpenClawDiscovery
@MainActor
enum GatewayDiscoverySelectionSupport {
static func applyRemoteSelection(
gateway: GatewayDiscoveryModel.DiscoveredGateway,
state: AppState)
{
if state.remoteTransport == .direct {
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
}
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
}
}

View File

@@ -347,21 +347,8 @@ actor GatewayEndpointStore {
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
func ensureRemoteControlTunnel() async throws -> UInt16 {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = OpenClawConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
try await self.requireRemoteMode()
if let url = try self.resolveDirectRemoteURL() {
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
@@ -425,22 +412,9 @@ actor GatewayEndpointStore {
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
try await self.requireRemoteMode()
let root = OpenClawConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
if let url = try self.resolveDirectRemoteURL() {
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
@@ -491,6 +465,27 @@ actor GatewayEndpointStore {
}
}
private func requireRemoteMode() async throws {
guard await self.deps.mode() == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
}
private func resolveDirectRemoteURL() throws -> URL? {
let root = OpenClawConfigFile.loadDict()
guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
return url
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}

View File

@@ -180,25 +180,11 @@ extension GatewayLaunchAgentManager {
}
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
return ParsedDaemonJson(text: jsonText, object: object)
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
return ParsedDaemonJson(text: parsed.text, object: parsed.object)
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "" : normalized
TextSummarySupport.summarizeLastLine(text)
}
}

View File

@@ -0,0 +1,34 @@
import OpenClawKit
enum GatewayPushSubscription {
@MainActor
static func consume(
bufferingNewest: Int? = nil,
onPush: @escaping @MainActor (GatewayPush) -> Void) async
{
let stream: AsyncStream<GatewayPush> = if let bufferingNewest {
await GatewayConnection.shared.subscribe(bufferingNewest: bufferingNewest)
} else {
await GatewayConnection.shared.subscribe()
}
for await push in stream {
if Task.isCancelled { return }
await MainActor.run {
onPush(push)
}
}
}
@MainActor
static func restartTask(
task: inout Task<Void, Never>?,
bufferingNewest: Int? = nil,
onPush: @escaping @MainActor (GatewayPush) -> Void)
{
task?.cancel()
task = Task {
await self.consume(bufferingNewest: bufferingNewest, onPush: onPush)
}
}
}

View File

@@ -1,41 +1,7 @@
import Foundation
import Network
import OpenClawKit
enum GatewayRemoteConfig {
private static func isLoopbackHost(_ rawHost: String) -> Bool {
var host = rawHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
if host.hasSuffix(".") {
host.removeLast()
}
if let zoneIndex = host.firstIndex(of: "%") {
host = String(host[..<zoneIndex])
}
if host.isEmpty {
return false
}
if host == "localhost" || host == "0.0.0.0" || host == "::" {
return true
}
if let ipv4 = IPv4Address(host) {
return ipv4.rawValue.first == 127
}
if let ipv6 = IPv6Address(host) {
let bytes = Array(ipv6.rawValue)
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
if isV6Loopback {
return true
}
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
return isMappedV4 && bytes[12] == 127
}
return false
}
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
@@ -74,7 +40,7 @@ enum GatewayRemoteConfig {
guard scheme == "ws" || scheme == "wss" else { return nil }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
if scheme == "ws", !self.isLoopbackHost(host) {
if scheme == "ws", !LoopbackHost.isLoopbackHost(host) {
return nil
}
if scheme == "ws", url.port == nil {

View File

@@ -260,17 +260,7 @@ struct GeneralSettings: View {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || !canTest)
self.remoteTestButton(disabled: !canTest)
}
if let validationMessage {
Text(validationMessage)
@@ -290,18 +280,8 @@ struct GeneralSettings: View {
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
self.remoteTestButton(
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
@@ -311,6 +291,20 @@ struct GeneralSettings: View {
}
}
private func remoteTestButton(disabled: Bool) -> some View {
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || disabled)
}
private var controlStatusLine: String {
switch ControlChannel.shared.state {
case .connected: "Connected"
@@ -672,19 +666,7 @@ extension GeneralSettings {
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
if self.state.remoteTransport == .direct {
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
}
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
}
}

View File

@@ -100,17 +100,8 @@ final class HoverHUDController {
return
}
let target = window.frame.offsetBy(dx: 0, dy: 6)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.14
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 0
} completionHandler: {
Task { @MainActor in
window.orderOut(nil)
self.model.isVisible = false
}
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) {
self.model.isVisible = false
}
}
@@ -140,15 +131,7 @@ final class HoverHUDController {
if !self.model.isVisible {
self.model.isVisible = true
let start = target.offsetBy(dx: 0, dy: 8)
window.setFrame(start, display: true)
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 1
}
OverlayPanelFactory.animatePresent(window: window, from: start, to: target)
} else {
window.orderFrontRegardless()
self.updateWindowFrame(animate: true)
@@ -157,22 +140,10 @@ final class HoverHUDController {
private func ensureWindow() {
if self.window != nil { return }
let panel = NSPanel(
let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered,
defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .statusBar
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
panel.hidesOnDeactivate = false
panel.isMovable = false
panel.isFloatingPanel = true
panel.becomesKeyOnlyIfNeeded = true
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
level: .statusBar,
hasShadow: true)
let host = NSHostingView(rootView: HoverHUDView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
@@ -201,17 +172,7 @@ final class HoverHUDController {
}
private func updateWindowFrame(animate: Bool = false) {
guard let window else { return }
let frame = self.targetFrame()
if animate {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(frame, display: true)
}
} else {
window.setFrame(frame, display: true)
}
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
private func installDismissMonitor() {
@@ -231,10 +192,7 @@ final class HoverHUDController {
}
private func removeDismissMonitor() {
if let monitor = self.dismissMonitor {
NSEvent.removeMonitor(monitor)
self.dismissMonitor = nil
}
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
}

View File

@@ -43,16 +43,8 @@ struct InstancesSettings: View {
.foregroundStyle(.secondary)
}
Spacer()
if self.store.isLoading {
ProgressView()
} else {
Button {
Task { await self.store.refresh() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.help("Refresh")
SettingsRefreshButton(isLoading: self.store.isLoading) {
Task { await self.store.refresh() }
}
}
}
@@ -276,7 +268,7 @@ struct InstancesSettings: View {
}
private func platformIcon(_ raw: String) -> String {
let (prefix, _) = self.parsePlatform(raw)
let (prefix, _) = PlatformLabelFormatter.parse(raw)
switch prefix {
case "macos":
return "laptopcomputer"
@@ -294,31 +286,7 @@ struct InstancesSettings: View {
}
private func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil }
let name: String = switch prefix {
case "macos": "macOS"
case "ios": "iOS"
case "ipados": "iPadOS"
case "tvos": "tvOS"
case "watchos": "watchOS"
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
}
guard let version, !version.isEmpty else { return name }
let parts = version.split(separator: ".").map(String.init)
if parts.count >= 2 {
return "\(name) \(parts[0]).\(parts[1])"
}
return "\(name) \(version)"
}
private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return ("", nil) }
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
let prefix = parts.first?.lowercased() ?? ""
let versionToken = parts.dropFirst().first
return (prefix, versionToken)
PlatformLabelFormatter.pretty(raw)
}
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
@@ -450,8 +418,8 @@ extension InstancesSettings {
_ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ")
_ = view.parsePlatform("macOS 14.1")
_ = view.parsePlatform(" ")
_ = PlatformLabelFormatter.parse("macOS 14.1")
_ = PlatformLabelFormatter.parse(" ")
_ = view.presenceUpdateSourceShortText("self")
_ = view.presenceUpdateSourceShortText("instances-refresh")
_ = view.presenceUpdateSourceShortText("seq gap")

View File

@@ -62,14 +62,11 @@ final class InstancesStore {
self.startCount += 1
guard self.startCount == 1 else { return }
guard self.task == nil else { return }
self.startGatewaySubscription()
self.task = Task.detached { [weak self] in
guard let self else { return }
await self.refresh()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refresh()
}
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
self?.handle(push: push)
}
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
await self?.refresh()
}
}
@@ -84,20 +81,6 @@ final class InstancesStore {
self.eventTask = nil
}
private func startGatewaySubscription() {
self.eventTask?.cancel()
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handle(push: push)
}
}
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "presence":

View File

@@ -0,0 +1,16 @@
import Foundation
enum JSONObjectExtractionSupport {
static func extract(from raw: String) -> (text: String, object: [String: Any])? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
return (jsonText, object)
}
}

View File

@@ -98,23 +98,42 @@ extension Logger.Message.StringInterpolation {
}
}
struct OpenClawOSLogHandler: LogHandler {
private let osLogger: os.Logger
var metadata: Logger.Metadata = [:]
private func stringifyLogMetadataValue(_ value: Logger.Metadata.Value) -> String {
switch value {
case let .string(text):
text
case let .stringConvertible(value):
String(describing: value)
case let .array(values):
"[" + values.map { stringifyLogMetadataValue($0) }.joined(separator: ",") + "]"
case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }.joined(separator: ",") + "}"
}
}
private protocol AppLogLevelBackedHandler: LogHandler {
var metadata: Logger.Metadata { get set }
}
extension AppLogLevelBackedHandler {
var logLevel: Logger.Level {
get { AppLogSettings.logLevel() }
set { AppLogSettings.setLogLevel(newValue) }
}
init(subsystem: String, category: String) {
self.osLogger = os.Logger(subsystem: subsystem, category: category)
}
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { self.metadata[key] }
set { self.metadata[key] = newValue }
}
}
struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
private let osLogger: os.Logger
var metadata: Logger.Metadata = [:]
init(subsystem: String, category: String) {
self.osLogger = os.Logger(subsystem: subsystem, category: category)
}
func log(
level: Logger.Level,
@@ -157,39 +176,16 @@ struct OpenClawOSLogHandler: LogHandler {
guard !metadata.isEmpty else { return message.description }
let meta = metadata
.sorted(by: { $0.key < $1.key })
.map { "\($0.key)=\(self.stringify($0.value))" }
.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }
.joined(separator: " ")
return "\(message.description) [\(meta)]"
}
private static func stringify(_ value: Logger.Metadata.Value) -> String {
switch value {
case let .string(text):
text
case let .stringConvertible(value):
String(describing: value)
case let .array(values):
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
}
}
}
struct OpenClawFileLogHandler: LogHandler {
struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
let label: String
var metadata: Logger.Metadata = [:]
var logLevel: Logger.Level {
get { AppLogSettings.logLevel() }
set { AppLogSettings.setLogLevel(newValue) }
}
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { self.metadata[key] }
set { self.metadata[key] = newValue }
}
func log(
level: Logger.Level,
message: Logger.Message,
@@ -212,21 +208,8 @@ struct OpenClawFileLogHandler: LogHandler {
]
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
for (key, value) in merged {
fields["meta.\(key)"] = Self.stringify(value)
fields["meta.\(key)"] = stringifyLogMetadataValue(value)
}
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
}
private static func stringify(_ value: Logger.Metadata.Value) -> String {
switch value {
case let .string(text):
text
case let .stringConvertible(value):
String(describing: value)
case let .array(values):
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
case let .dictionary(entries):
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
}
}
}

View File

@@ -228,17 +228,7 @@ private final class StatusItemMouseHandlerView: NSView {
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let options: NSTrackingArea.Options = [
.mouseEnteredAndExited,
.activeAlways,
.inVisibleRect,
]
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
}
override func mouseEntered(with event: NSEvent) {

View File

@@ -170,7 +170,11 @@ struct MenuContent: View {
await self.loadBrowserControlEnabled()
}
.onAppear {
self.startMicObserver()
MicRefreshSupport.startObserver(self.micObserver) {
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
await self.loadMicrophones(force: true)
}
}
}
.onDisappear {
self.micRefreshTask?.cancel()
@@ -425,11 +429,7 @@ struct MenuContent: View {
}
private var voiceWakeBinding: Binding<Bool> {
Binding(
get: { self.state.swabbleEnabled },
set: { newValue in
Task { await self.state.setVoiceWakeEnabled(newValue) }
})
MicRefreshSupport.voiceWakeBinding(for: self.state)
}
private var showVoiceWakeMicPicker: Bool {
@@ -546,46 +546,20 @@ struct MenuContent: View {
}
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
self.availableMics = self.filterAliveInputs(self.availableMics)
self.updateSelectedMicName()
self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
selectedID: self.state.voiceWakeMicID,
in: self.availableMics,
uid: \.uid,
name: \.name)
self.loadingMics = false
}
private func startMicObserver() {
self.micObserver.start {
Task { @MainActor in
self.scheduleMicRefresh()
}
}
}
@MainActor
private func scheduleMicRefresh() {
self.micRefreshTask?.cancel()
self.micRefreshTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
await self.loadMicrophones(force: true)
}
}
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
guard !aliveUIDs.isEmpty else { return inputs }
return inputs.filter { aliveUIDs.contains($0.uid) }
}
@MainActor
private func updateSelectedMicName() {
let selected = self.state.voiceWakeMicID
if selected.isEmpty {
self.state.voiceWakeMicName = ""
return
}
if let match = self.availableMics.first(where: { $0.uid == selected }) {
self.state.voiceWakeMicName = match.name
}
}
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String

View File

@@ -0,0 +1,52 @@
import SwiftUI
struct MenuHeaderCard<Content: View>: View {
let title: String
let subtitle: String
let statusText: String?
let paddingBottom: CGFloat
@ViewBuilder var content: Content
init(
title: String,
subtitle: String,
statusText: String? = nil,
paddingBottom: CGFloat = 6,
@ViewBuilder content: () -> Content = { EmptyView() })
{
self.title = title
self.subtitle = subtitle
self.statusText = statusText
self.paddingBottom = paddingBottom
self.content = content()
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text(self.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
self.content
}
.padding(.top, 8)
.padding(.bottom, self.paddingBottom)
.padding(.leading, 20)
.padding(.trailing, 10)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
}
}

View File

@@ -33,17 +33,7 @@ final class HighlightedMenuItemHostView: NSView {
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking {
self.removeTrackingArea(tracking)
}
let options: NSTrackingArea.Options = [
.mouseEnteredAndExited,
.activeAlways,
.inVisibleRect,
]
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
self.addTrackingArea(area)
self.tracking = area
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
}
override func mouseEntered(with event: NSEvent) {

View File

@@ -0,0 +1,22 @@
import SwiftUI
enum MenuItemHighlightColors {
struct Palette {
let primary: Color
let secondary: Color
}
static func primary(_ highlighted: Bool) -> Color {
highlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
static func secondary(_ highlighted: Bool) -> Color {
highlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
}
static func palette(_ highlighted: Bool) -> Palette {
Palette(
primary: self.primary(highlighted),
secondary: self.secondary(highlighted))
}
}

View File

@@ -4,37 +4,11 @@ struct MenuSessionsHeaderView: View {
let count: Int
let statusText: String?
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Context")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
if let statusText, !statusText.isEmpty {
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
MenuHeaderCard(
title: "Context",
subtitle: self.subtitle,
statusText: self.statusText)
}
private var subtitle: String {

View File

@@ -3,29 +3,10 @@ import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
private let paddingTop: CGFloat = 8
private let paddingBottom: CGFloat = 6
private let paddingTrailing: CGFloat = 10
private let paddingLeading: CGFloat = 20
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
Text("Usage")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer(minLength: 10)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.top, self.paddingTop)
.padding(.bottom, self.paddingBottom)
.padding(.leading, self.paddingLeading)
.padding(.trailing, self.paddingTrailing)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.transaction { txn in txn.animation = nil }
MenuHeaderCard(
title: "Usage",
subtitle: self.subtitle)
}
private var subtitle: String {

View File

@@ -0,0 +1,46 @@
import Foundation
import SwiftUI
enum MicRefreshSupport {
private static let refreshDelayNs: UInt64 = 300_000_000
static func startObserver(_ observer: AudioInputDeviceObserver, triggerRefresh: @escaping @MainActor () -> Void) {
observer.start {
Task { @MainActor in
triggerRefresh()
}
}
}
@MainActor
static func schedule(
refreshTask: inout Task<Void, Never>?,
action: @escaping @MainActor () async -> Void)
{
refreshTask?.cancel()
refreshTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: self.refreshDelayNs)
guard !Task.isCancelled else { return }
await action()
}
}
static func selectedMicName<T>(
selectedID: String,
in devices: [T],
uid: KeyPath<T, String>,
name: KeyPath<T, String>) -> String
{
guard !selectedID.isEmpty else { return "" }
return devices.first(where: { $0[keyPath: uid] == selectedID })?[keyPath: name] ?? ""
}
@MainActor
static func voiceWakeBinding(for state: AppState) -> Binding<Bool> {
Binding(
get: { state.swabbleEnabled },
set: { newValue in
Task { await state.setVoiceWakeEnabled(newValue) }
})
}
}

View File

@@ -3,7 +3,7 @@ import Foundation
import OpenClawKit
@MainActor
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
enum Error: Swift.Error {
case timeout
case unavailable
@@ -12,21 +12,18 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
var locationManager: CLLocationManager {
self.manager
}
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
get { self.locationContinuation }
set { self.locationContinuation = newValue }
}
override init() {
super.init()
self.manager.delegate = self
self.manager.desiredAccuracy = kCLLocationAccuracyBest
}
func authorizationStatus() -> CLAuthorizationStatus {
self.manager.authorizationStatus
}
func accuracyAuthorization() -> CLAccuracyAuthorization {
if #available(macOS 11.0, *) {
return self.manager.accuracyAuthorization
}
return .fullAccuracy
self.configureLocationManager()
}
func currentLocation(
@@ -37,26 +34,15 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
let now = Date()
if let maxAgeMs,
let cached = self.manager.location,
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
{
return cached
}
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
let timeout = max(0, timeoutMs ?? 10000)
return try await self.withTimeout(timeoutMs: timeout) {
try await self.requestLocation()
}
}
private func requestLocation() async throws -> CLLocation {
try await withCheckedThrowingContinuation { cont in
self.locationContinuation = cont
self.manager.requestLocation()
return try await LocationCurrentRequest.resolve(
manager: self.manager,
desiredAccuracy: desiredAccuracy,
maxAgeMs: maxAgeMs,
timeoutMs: timeoutMs,
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
try await self.withTimeout(timeoutMs: timeoutMs) {
try await operation()
}
}
}
@@ -103,17 +89,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
}
}
private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
switch accuracy {
case .coarse:
kCLLocationAccuracyKilometer
case .balanced:
kCLLocationAccuracyHundredMeters
case .precise:
kCLLocationAccuracyBest
}
}
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

View File

@@ -32,9 +32,7 @@ final class NodePairingApprovalPrompter {
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
private var activeAlert: NSAlert?
private var activeRequestId: String?
private var alertHostWindow: NSWindow?
private let alertState = PairingAlertState()
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private var autoApproveAttempts: Set<String> = []
@@ -68,55 +66,43 @@ final class NodePairingApprovalPrompter {
}
}
private struct PairingResolvedEvent: Codable {
let requestId: String
let nodeId: String
let decision: String
let ts: Double
}
private enum PairingResolution: String {
case approved
case rejected
}
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
private typealias PairingResolution = PairingAlertSupport.PairingResolution
func start() {
guard self.task == nil else { return }
self.isStopping = false
self.reconcileTask?.cancel()
self.reconcileTask = nil
self.task = Task { [weak self] in
guard let self else { return }
_ = try? await GatewayConnection.shared.refresh()
await self.loadPendingRequestsFromGateway()
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in self?.handle(push: push) }
}
}
self.startPushTask()
}
private func startPushTask() {
PairingAlertSupport.startPairingPushTask(
task: &self.task,
isStopping: &self.isStopping,
loadPending: self.loadPendingRequestsFromGateway,
handlePush: self.handle(push:))
}
func stop() {
self.isStopping = true
self.endActiveAlert()
self.task?.cancel()
self.task = nil
self.stopPushTask()
self.reconcileTask?.cancel()
self.reconcileTask = nil
self.reconcileOnceTask?.cancel()
self.reconcileOnceTask = nil
self.queue.removeAll(keepingCapacity: false)
self.updatePendingCounts()
self.isPresenting = false
self.activeRequestId = nil
self.alertHostWindow?.orderOut(nil)
self.alertHostWindow?.close()
self.alertHostWindow = nil
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
self.autoApproveAttempts.removeAll(keepingCapacity: false)
}
private func stopPushTask() {
PairingAlertSupport.stopPairingPrompter(
isStopping: &self.isStopping,
task: &self.task,
queue: &self.queue,
isPresenting: &self.isPresenting,
state: self.alertState)
}
private func loadPendingRequestsFromGateway() async {
// The gateway process may start slightly after the app. Retry a bit so
// pending pairing prompts are still shown on launch.
@@ -190,7 +176,7 @@ final class NodePairingApprovalPrompter {
if pendingById[req.requestId] != nil { continue }
let resolution = self.inferResolution(for: req, list: list)
if self.activeRequestId == req.requestId, self.activeAlert != nil {
if self.alertState.activeRequestId == req.requestId, self.alertState.activeAlert != nil {
self.remoteResolutionsByRequestId[req.requestId] = resolution
self.logger.info(
"""
@@ -232,11 +218,7 @@ final class NodePairingApprovalPrompter {
}
private func endActiveAlert() {
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
}
private func requireAlertHostWindow() -> NSWindow {
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
@@ -293,47 +275,13 @@ final class NodePairingApprovalPrompter {
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow node to connect?"
alert.informativeText = Self.describe(req)
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
self.activeAlert = alert
self.activeRequestId = req.requestId
let hostWindow = self.requireAlertHostWindow()
// Position the hidden host window so the sheet appears centered on screen.
// (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".)
let sheetSize = alert.window.frame.size
if let screen = hostWindow.screen ?? NSScreen.main {
let bounds = screen.visibleFrame
let x = bounds.midX - (sheetSize.width / 2)
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
} else {
hostWindow.center()
}
hostWindow.makeKeyAndOrderFront(nil)
alert.beginSheetModal(for: hostWindow) { [weak self] response in
Task { @MainActor [weak self] in
guard let self else { return }
self.activeRequestId = nil
self.activeAlert = nil
await self.handleAlertResponse(response, request: req)
hostWindow.orderOut(nil)
}
}
PairingAlertSupport.presentPairingAlert(
request: req,
requestId: req.requestId,
messageText: "Allow node to connect?",
informativeText: Self.describe(req),
state: self.alertState,
onResponse: self.handleAlertResponse)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
@@ -373,24 +321,22 @@ final class NodePairingApprovalPrompter {
}
private func approve(requestId: String) async -> Bool {
do {
await PairingAlertSupport.approveRequest(
requestId: requestId,
kind: "node",
logger: self.logger)
{
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
return true
} catch {
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
private func reject(requestId: String) async {
do {
await PairingAlertSupport.rejectRequest(
requestId: requestId,
kind: "node",
logger: self.logger)
{
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
} catch {
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -419,8 +365,7 @@ final class NodePairingApprovalPrompter {
private static func prettyPlatform(_ platform: String?) -> String? {
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let raw, !raw.isEmpty else { return nil }
if raw.lowercased() == "ios" { return "iOS" }
if raw.lowercased() == "macos" { return "macOS" }
if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty }
return raw
}
@@ -616,7 +561,7 @@ final class NodePairingApprovalPrompter {
let resolution: PairingResolution =
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
if self.alertState.activeRequestId == resolved.requestId, self.alertState.activeAlert != nil {
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
self.logger.info(
"""

View File

@@ -103,15 +103,9 @@ extension NodeServiceManager {
}
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard let start = trimmed.firstIndex(of: "{"),
let end = trimmed.lastIndex(of: "}")
else {
return nil
}
let jsonText = String(trimmed[start...end])
guard let data = jsonText.data(using: .utf8) else { return nil }
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
let jsonText = parsed.text
let object = parsed.object
let ok = object["ok"] as? Bool
let result = object["result"] as? String
let message = object["message"] as? String
@@ -139,12 +133,6 @@ extension NodeServiceManager {
}
private static func summarize(_ text: String) -> String? {
let lines = text
.split(whereSeparator: \.isNewline)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard let last = lines.last else { return nil }
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
return normalized.count > 200 ? String(normalized.prefix(199)) + "" : normalized
TextSummarySupport.summarizeLastLine(text)
}
}

View File

@@ -68,7 +68,7 @@ struct NodeMenuEntryFormatter {
static func platformText(_ entry: NodeInfo) -> String? {
if let raw = entry.platform?.nonEmpty {
return self.prettyPlatform(raw) ?? raw
return PlatformLabelFormatter.pretty(raw) ?? raw
}
if let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") { return "macOS" }
@@ -79,34 +79,6 @@ struct NodeMenuEntryFormatter {
return nil
}
private static func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil }
let name: String = switch prefix {
case "macos": "macOS"
case "ios": "iOS"
case "ipados": "iPadOS"
case "tvos": "tvOS"
case "watchos": "watchOS"
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
}
guard let version, !version.isEmpty else { return name }
let parts = version.split(separator: ".").map(String.init)
if parts.count >= 2 {
return "\(name) \(parts[0]).\(parts[1])"
}
return "\(name) \(version)"
}
private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return ("", nil) }
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
let prefix = parts.first?.lowercased() ?? ""
let versionToken = parts.dropFirst().first
return (prefix, versionToken)
}
private static func compactVersion(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
@@ -201,12 +173,8 @@ struct NodeMenuRowView: View {
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
private var primaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
private var secondaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
private var palette: MenuItemHighlightColors.Palette {
MenuItemHighlightColors.palette(self.isHighlighted)
}
var body: some View {
@@ -216,9 +184,9 @@ struct NodeMenuRowView: View {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.primaryName(self.entry))
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
.foregroundStyle(self.primaryColor)
Text(NodeMenuEntryFormatter.primaryName(self.entry))
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
.foregroundStyle(self.palette.primary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
@@ -227,9 +195,9 @@ struct NodeMenuRowView: View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
Text(right)
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryColor)
Text(right)
.font(.caption.monospacedDigit())
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(2)
@@ -237,7 +205,7 @@ struct NodeMenuRowView: View {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryColor)
.foregroundStyle(self.palette.secondary)
.padding(.leading, 2)
}
}
@@ -245,7 +213,7 @@ struct NodeMenuRowView: View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
.font(.caption)
.foregroundStyle(self.secondaryColor)
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
@@ -254,7 +222,7 @@ struct NodeMenuRowView: View {
if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) {
Text(version)
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryColor)
.foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
@@ -273,11 +241,11 @@ struct NodeMenuRowView: View {
private var leadingIcon: some View {
if NodeMenuEntryFormatter.isAndroid(self.entry) {
AndroidMark()
.foregroundStyle(self.secondaryColor)
.foregroundStyle(self.palette.secondary)
} else {
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
.font(.system(size: 18, weight: .regular))
.foregroundStyle(self.secondaryColor)
.foregroundStyle(self.palette.secondary)
}
}
}
@@ -305,23 +273,19 @@ struct NodeMenuMultilineView: View {
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
private var primaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
private var secondaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
private var palette: MenuItemHighlightColors.Palette {
MenuItemHighlightColors.palette(self.isHighlighted)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(self.label):")
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryColor)
.foregroundStyle(self.palette.secondary)
Text(self.value)
.font(.caption)
.foregroundStyle(self.primaryColor)
.foregroundStyle(self.palette.primary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}

View File

@@ -54,14 +54,8 @@ final class NodesStore {
func start() {
self.startCount += 1
guard self.startCount == 1 else { return }
guard self.task == nil else { return }
self.task = Task.detached { [weak self] in
guard let self else { return }
await self.refresh()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refresh()
}
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
await self?.refresh()
}
}

View File

@@ -50,17 +50,8 @@ final class NotifyOverlayController {
self.dismissTask = nil
guard let window else { return }
let target = window.frame.offsetBy(dx: 8, dy: 6)
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 0
} completionHandler: {
Task { @MainActor in
window.orderOut(nil)
self.model.isVisible = false
}
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 8, offsetY: 6) {
self.model.isVisible = false
}
}
@@ -70,44 +61,21 @@ final class NotifyOverlayController {
self.ensureWindow()
self.hostingView?.rootView = NotifyOverlayView(controller: self)
let target = self.targetFrame()
guard let window else { return }
if !self.model.isVisible {
self.model.isVisible = true
let start = target.offsetBy(dx: 0, dy: -6)
window.setFrame(start, display: true)
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.18
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 1
}
} else {
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
target: target) { window in
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
}
}
private func ensureWindow() {
if self.window != nil { return }
let panel = NSPanel(
let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered,
defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .statusBar
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
panel.hidesOnDeactivate = false
panel.isMovable = false
panel.isFloatingPanel = true
panel.becomesKeyOnlyIfNeeded = true
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
level: .statusBar,
hasShadow: true)
let host = NSHostingView(rootView: NotifyOverlayView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
@@ -126,17 +94,7 @@ final class NotifyOverlayController {
}
private func updateWindowFrame(animate: Bool = false) {
guard let window else { return }
let frame = self.targetFrame()
if animate {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(frame, display: true)
}
} else {
window.setFrame(frame, display: true)
}
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
private func measuredHeight() -> CGFloat {

View File

@@ -24,19 +24,7 @@ extension OnboardingView {
Task { await self.onboardingWizard.cancelIfRunning() }
self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if self.state.remoteTransport == .direct {
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
} else {
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
}
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
OpenClawConfigFile.setRemoteGatewayUrl(
host: endpoint.host,
port: endpoint.port)
} else {
OpenClawConfigFile.clearRemoteGatewayUrl()
}
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
self.state.connectionMode = .remote
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)

View File

@@ -189,19 +189,7 @@ extension OnboardingView {
}
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
self.featureRowContent(title: title, subtitle: subtitle, systemImage: systemImage)
}
func featureActionRow(
@@ -210,6 +198,22 @@ extension OnboardingView {
systemImage: String,
buttonTitle: String,
action: @escaping () -> Void) -> some View
{
self.featureRowContent(
title: title,
subtitle: subtitle,
systemImage: systemImage,
action: AnyView(
Button(buttonTitle, action: action)
.buttonStyle(.link)
.padding(.top, 2)))
}
private func featureRowContent(
title: String,
subtitle: String,
systemImage: String,
action: AnyView? = nil) -> some View
{
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
@@ -221,9 +225,9 @@ extension OnboardingView {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
Button(buttonTitle, action: action)
.buttonStyle(.link)
.padding(.top, 2)
if let action {
action
}
}
Spacer(minLength: 0)
}

View File

@@ -17,14 +17,9 @@ extension OnboardingView {
}
func updatePermissionMonitoring(for pageIndex: Int) {
let shouldMonitor = pageIndex == self.permissionsPageIndex
if shouldMonitor, !self.monitoringPermissions {
self.monitoringPermissions = true
PermissionMonitor.shared.register()
} else if !shouldMonitor, self.monitoringPermissions {
self.monitoringPermissions = false
PermissionMonitor.shared.unregister()
}
PermissionMonitoringSupport.setMonitoring(
pageIndex == self.permissionsPageIndex,
monitoring: &self.monitoringPermissions)
}
func updateDiscoveryMonitoring(for pageIndex: Int) {
@@ -51,9 +46,7 @@ extension OnboardingView {
}
func stopPermissionMonitoring() {
guard self.monitoringPermissions else { return }
self.monitoringPermissions = false
PermissionMonitor.shared.unregister()
PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions)
}
func stopDiscovery() {

View File

@@ -311,29 +311,13 @@ extension OnboardingView {
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
.truncationMode(.middle)
}
}
Spacer(minLength: 0)
if selected {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Color.accentColor)
} else {
Image(systemName: "arrow.right.circle")
.foregroundStyle(.secondary)
}
SelectionStateIndicator(selected: selected)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(selected ? Color.accentColor.opacity(0.12) : Color.clear))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(
selected ? Color.accentColor.opacity(0.45) : Color.clear,
lineWidth: 1))
.openClawSelectableRowChrome(selected: selected)
}
.buttonStyle(.plain)
}

View File

@@ -69,9 +69,7 @@ extension OnboardingView {
private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load()
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
return AgentWorkspaceConfig.workspace(from: root)
}
@discardableResult
@@ -87,24 +85,7 @@ extension OnboardingView {
@MainActor
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
var root = await ConfigStore.load()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace")
} else {
defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
do {
try await ConfigStore.save(root)
return (true, nil)

View File

@@ -127,34 +127,15 @@ enum OpenClawConfigFile {
}
static func agentWorkspace() -> String? {
let root = self.loadDict()
let agents = root["agents"] as? [String: Any]
let defaults = agents?["defaults"] as? [String: Any]
return defaults?["workspace"] as? String
AgentWorkspaceConfig.workspace(from: self.loadDict())
}
static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict()
var agents = root["agents"] as? [String: Any] ?? [:]
var defaults = agents["defaults"] as? [String: Any] ?? [:]
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
defaults.removeValue(forKey: "workspace")
} else {
defaults["workspace"] = trimmed
}
if defaults.isEmpty {
agents.removeValue(forKey: "defaults")
} else {
agents["defaults"] = defaults
}
if agents.isEmpty {
root.removeValue(forKey: "agents")
} else {
root["agents"] = agents
}
AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
self.saveDict(root)
self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
let hasWorkspace = !(workspace?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
self.logger.debug("agents.defaults.workspace updated set=\(hasWorkspace)")
}
static func gatewayPassword() -> String? {
@@ -249,7 +230,7 @@ enum OpenClawConfigFile {
return url
}
private static func hostKey(_ host: String) -> String {
static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }

View File

@@ -0,0 +1,126 @@
import AppKit
import QuartzCore
enum OverlayPanelFactory {
@MainActor
static func makePanel(
contentRect: NSRect,
level: NSWindow.Level,
hasShadow: Bool,
acceptsMouseMovedEvents: Bool = false) -> NSPanel
{
let panel = NSPanel(
contentRect: contentRect,
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered,
defer: false)
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = hasShadow
panel.level = level
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
panel.hidesOnDeactivate = false
panel.isMovable = false
panel.isFloatingPanel = true
panel.becomesKeyOnlyIfNeeded = true
panel.titleVisibility = .hidden
panel.titlebarAppearsTransparent = true
panel.acceptsMouseMovedEvents = acceptsMouseMovedEvents
return panel
}
@MainActor
static func animatePresent(window: NSWindow, from start: NSRect, to target: NSRect, duration: TimeInterval = 0.18) {
window.setFrame(start, display: true)
window.alphaValue = 0
window.orderFrontRegardless()
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 1
}
}
@MainActor
static func animateFrame(window: NSWindow, to frame: NSRect, duration: TimeInterval = 0.12) {
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(frame, display: true)
}
}
@MainActor
static func applyFrame(window: NSWindow?, target: NSRect, animate: Bool) {
guard let window else { return }
if animate {
self.animateFrame(window: window, to: target)
} else {
window.setFrame(target, display: true)
}
}
@MainActor
static func present(
window: NSWindow?,
isVisible: inout Bool,
target: NSRect,
startOffsetY: CGFloat = -6,
onFirstPresent: (() -> Void)? = nil,
onAlreadyVisible: (NSWindow) -> Void)
{
guard let window else { return }
if !isVisible {
isVisible = true
onFirstPresent?()
let start = target.offsetBy(dx: 0, dy: startOffsetY)
self.animatePresent(window: window, from: start, to: target)
} else {
onAlreadyVisible(window)
}
}
@MainActor
static func animateDismiss(
window: NSWindow,
offsetX: CGFloat = 6,
offsetY: CGFloat = 6,
duration: TimeInterval = 0.16,
completion: @escaping () -> Void)
{
let target = window.frame.offsetBy(dx: offsetX, dy: offsetY)
NSAnimationContext.runAnimationGroup { context in
context.duration = duration
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 0
} completionHandler: {
completion()
}
}
@MainActor
static func animateDismissAndHide(
window: NSWindow,
offsetX: CGFloat = 6,
offsetY: CGFloat = 6,
duration: TimeInterval = 0.16,
onHidden: @escaping @MainActor () -> Void)
{
self.animateDismiss(window: window, offsetX: offsetX, offsetY: offsetY, duration: duration) {
Task { @MainActor in
window.orderOut(nil)
onHidden()
}
}
}
@MainActor
static func clearGlobalEventMonitor(_ monitor: inout Any?) {
if let current = monitor {
NSEvent.removeMonitor(current)
monitor = nil
}
}
}

View File

@@ -1,4 +1,6 @@
import AppKit
import OpenClawKit
import OSLog
final class PairingAlertHostWindow: NSWindow {
override var canBecomeKey: Bool {
@@ -10,8 +12,26 @@ final class PairingAlertHostWindow: NSWindow {
}
}
@MainActor
final class PairingAlertState {
var activeAlert: NSAlert?
var activeRequestId: String?
var alertHostWindow: NSWindow?
}
@MainActor
enum PairingAlertSupport {
enum PairingResolution: String {
case approved
case rejected
}
struct PairingResolvedEvent: Codable {
let requestId: String
let decision: String
let ts: Double
}
static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
guard let alert = activeAlert else { return }
if let parent = alert.window.sheetParent {
@@ -21,6 +41,10 @@ enum PairingAlertSupport {
activeRequestId = nil
}
static func endActiveAlert(state: PairingAlertState) {
self.endActiveAlert(activeAlert: &state.activeAlert, activeRequestId: &state.activeRequestId)
}
static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow {
if let alertHostWindow {
return alertHostWindow
@@ -43,4 +67,237 @@ enum PairingAlertSupport {
alertHostWindow = window
return window
}
static func configureDefaultPairingAlert(
_ alert: NSAlert,
messageText: String,
informativeText: String)
{
alert.alertStyle = .warning
alert.messageText = messageText
alert.informativeText = informativeText
alert.addButton(withTitle: "Later")
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
alert.buttons[2].hasDestructiveAction = true
}
}
static func beginCenteredSheet(
alert: NSAlert,
hostWindow: NSWindow,
completionHandler: @escaping (NSApplication.ModalResponse) -> Void)
{
let sheetSize = alert.window.frame.size
if let screen = hostWindow.screen ?? NSScreen.main {
let bounds = screen.visibleFrame
let x = bounds.midX - (sheetSize.width / 2)
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
} else {
hostWindow.center()
}
hostWindow.makeKeyAndOrderFront(nil)
alert.beginSheetModal(for: hostWindow, completionHandler: completionHandler)
}
static func runPairingPushTask(
bufferingNewest: Int = 200,
loadPending: @escaping @MainActor () async -> Void,
handlePush: @escaping @MainActor (GatewayPush) -> Void) async
{
_ = try? await GatewayConnection.shared.refresh()
await loadPending()
await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush)
}
static func startPairingPushTask(
task: inout Task<Void, Never>?,
isStopping: inout Bool,
bufferingNewest: Int = 200,
loadPending: @escaping @MainActor () async -> Void,
handlePush: @escaping @MainActor (GatewayPush) -> Void)
{
guard task == nil else { return }
isStopping = false
task = Task {
await self.runPairingPushTask(
bufferingNewest: bufferingNewest,
loadPending: loadPending,
handlePush: handlePush)
}
}
static func beginPairingAlert(
messageText: String,
informativeText: String,
alertHostWindow: inout NSWindow?,
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert {
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
self.configureDefaultPairingAlert(alert, messageText: messageText, informativeText: informativeText)
let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow)
self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in
completion(response, hostWindow)
}
return alert
}
static func presentPairingAlert(
requestId: String,
messageText: String,
informativeText: String,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
alertHostWindow: inout NSWindow?,
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void)
{
activeRequestId = requestId
activeAlert = self.beginPairingAlert(
messageText: messageText,
informativeText: informativeText,
alertHostWindow: &alertHostWindow,
completion: completion)
}
static func presentPairingAlert<Request>(
request: Request,
requestId: String,
messageText: String,
informativeText: String,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
alertHostWindow: inout NSWindow?,
clearActive: @escaping @MainActor (NSWindow) -> Void,
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
{
self.presentPairingAlert(
requestId: requestId,
messageText: messageText,
informativeText: informativeText,
activeAlert: &activeAlert,
activeRequestId: &activeRequestId,
alertHostWindow: &alertHostWindow)
{ response, hostWindow in
Task { @MainActor in
clearActive(hostWindow)
await onResponse(response, request)
}
}
}
static func presentPairingAlert<Request>(
request: Request,
requestId: String,
messageText: String,
informativeText: String,
state: PairingAlertState,
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
{
self.presentPairingAlert(
request: request,
requestId: requestId,
messageText: messageText,
informativeText: informativeText,
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
alertHostWindow: &state.alertHostWindow)
{ response, hostWindow in
Task { @MainActor in
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
await onResponse(response, request)
}
}
}
static func clearActivePairingAlert(
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
hostWindow: NSWindow)
{
activeRequestId = nil
activeAlert = nil
hostWindow.orderOut(nil)
}
static func clearActivePairingAlert(state: PairingAlertState, hostWindow: NSWindow) {
self.clearActivePairingAlert(
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
hostWindow: hostWindow)
}
static func stopPairingPrompter<Request>(
isStopping: inout Bool,
activeAlert: inout NSAlert?,
activeRequestId: inout String?,
task: inout Task<Void, Never>?,
queue: inout [Request],
isPresenting: inout Bool,
alertHostWindow: inout NSWindow?)
{
isStopping = true
self.endActiveAlert(activeAlert: &activeAlert, activeRequestId: &activeRequestId)
task?.cancel()
task = nil
queue.removeAll(keepingCapacity: false)
isPresenting = false
activeRequestId = nil
alertHostWindow?.orderOut(nil)
alertHostWindow?.close()
alertHostWindow = nil
}
static func stopPairingPrompter<Request>(
isStopping: inout Bool,
task: inout Task<Void, Never>?,
queue: inout [Request],
isPresenting: inout Bool,
state: PairingAlertState)
{
self.stopPairingPrompter(
isStopping: &isStopping,
activeAlert: &state.activeAlert,
activeRequestId: &state.activeRequestId,
task: &task,
queue: &queue,
isPresenting: &isPresenting,
alertHostWindow: &state.alertHostWindow)
}
static func approveRequest(
requestId: String,
kind: String,
logger: Logger,
action: @escaping () async throws -> Void) async -> Bool
{
do {
try await action()
logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
return true
} catch {
logger.error("approve failed requestId=\(requestId, privacy: .public)")
logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
static func rejectRequest(
requestId: String,
kind: String,
logger: Logger,
action: @escaping () async throws -> Void) async
{
do {
try await action()
logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
} catch {
logger.error("reject failed requestId=\(requestId, privacy: .public)")
logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
}

Some files were not shown because too many files have changed in this diff Show More