Compare commits

...

45 Commits

Author SHA1 Message Date
Peter Steinberger
8a91af22a5 fix: clean up codex inline model api fallback (#39753) (thanks @justinhuangcode) 2026-03-08 13:51:18 +00:00
justinhuangcode
e4bfcff5a8 chore: update secrets baseline line numbers 2026-03-08 13:49:02 +00:00
justinhuangcode
c42dc2e8c2 fix(agents): let forward-compat resolve api when inline model omits it
When a user configures `models.providers.openai-codex` with a models
array but omits the `api` field, `buildInlineProviderModels` produces
an entry with `api: undefined`.  The inline-match early return then
hands this incomplete model straight to the caller, skipping the
forward-compat resolver that would supply the correct
`openai-codex-responses` api — causing a crash loop.

Let the inline match fall through to forward-compat when `api` is
absent so the resolver chain can fill it in.

Fixes #39682
2026-03-08 13:49:02 +00:00
Kros Dai
e9d51d874b Models: fix codex follow-up CI issues 2026-03-08 13:48:13 +00:00
Kros Dai
ec75643a09 Models: scope implicit codex baseUrl override 2026-03-08 13:48:13 +00:00
Kros Dai
374001c4a0 fix: add implicit openai-codex provider snapshot 2026-03-08 13:48:13 +00:00
Felix Hellström
58ae5582f4 macOS: fix VoiceWakeOverlayController exclusivity violation #39275 2026-03-08 13:47:27 +00:00
Peter Steinberger
eebee84093 fix(models): discover Vercel AI Gateway catalog 2026-03-08 13:44:10 +00:00
Peter Steinberger
386b811ddd test(cron): relax concurrent start race timeout 2026-03-08 13:44:10 +00:00
Peter Steinberger
f66cc886d3 test(agents): normalize live model not-found skips 2026-03-08 13:44:10 +00:00
daymade
f930fcbd3f Add regression test and CHANGELOG entry
- Add test ensuring launchd path never returns "failed" status
- Add CHANGELOG.md entry documenting the fix with issue/PR references
- Reference ThrottleInterval evolution (#27650#29078 → current 1s)
2026-03-08 13:42:50 +00:00
daymade
03aea082d0 chore: condense inline comments per code review
Remove redundant rationale from test body (test names already convey it)
and trim the production comment to what/consequence/link (mechanism
details live in #39760).
2026-03-08 13:42:50 +00:00
daymade
5f45e76d61 fix(darwin): remove self-kickstart from launchd gateway restart; rely on KeepAlive
When the gateway needs a config-triggered restart under launchd, calling
`launchctl kickstart -k` from within the service itself races with
launchd's async bootout state machine:

1. `kickstart -k` initiates a launchd bootout → SIGTERM to self
2. Gateway ignores SIGTERM during shutdown → process doesn't exit
3. 2s `spawnSync` timeout kills the launchctl child, but launchd
   continues the bootout asynchronously
4. Fallback `launchctl bootstrap` fails with EIO (service mid-bootout)
5. In-process restart runs on the same PID that launchd will SIGKILL
6. LaunchAgent is permanently unloaded — no auto-restart

Fix: on darwin/launchd, skip `triggerOpenClawRestart()` entirely.
The caller already calls `exitProcess(0)` for supervised mode, and
`KeepAlive=true` (always set in the plist template) restarts the
service within ~1 second.

The schtasks (Windows) path is unchanged — Windows doesn't have an
equivalent KeepAlive mechanism.
2026-03-08 13:42:50 +00:00
Peter Steinberger
53fb317e7f fix(macos): clean swiftformat pass and sendable warning 2026-03-08 13:22:46 +00:00
Ayaan Zaidi
eb0758e172 docs(changelog): note Android Play policy cutovers 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
04b4b48077 fix(android): persist legacy location mode migration 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
709e11ea70 build(android): bump release version code 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
46145fde19 fix(android): remove mic and screen foreground services 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
1230cefe25 fix(android): remove background location mode 2026-03-08 16:25:49 +05:30
Ayaan Zaidi
0f9566b0b5 fix(android): remove self-update install flow 2026-03-08 16:25:49 +05:30
arceus77-7
492fe679a7 feat(tui): infer workspace agent when launching TUI (#39591)
Merged via squash.

Prepared head SHA: 23533e24c4
Co-authored-by: arceus77-7 <261276524+arceus77-7@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 13:31:11 +03:00
Altay
f4c4856254 docs(changelog): add #39377 failover note (#39704) 2026-03-08 13:09:26 +03:00
gambletan
8a20f51460 fix: add rate limit patterns for 'too many tokens' and 'tokens per day' (#39377)
Merged via squash.

Prepared head SHA: 132a457286
Co-authored-by: gambletan <266203672+gambletan@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 13:03:33 +03:00
Farhoud Cheraghi
aedf3ee68f fix(skills): expand skill-creator description to cover edit/audit/review triggers (#39158)
Merged via squash.

Prepared head SHA: 13997c1ee5
Co-authored-by: haynzz <1236319+haynzz@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:13:00 +03:00
J. Campbell
b38f371630 fix: add @tloncorp/api to pnpm onlyBuiltDependencies allowlist (#39027)
Merged via squash.

Prepared head SHA: e149350260
Co-authored-by: apexfork <363026+apexfork@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:07:10 +03:00
gambletan
e5fdfec9dc fix(config): accept "openclaw" as browser profile driver in Zod schema (#39374)
Merged via squash.

Prepared head SHA: 0eba5ab939
Co-authored-by: gambletan <266203672+gambletan@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:04:49 +03:00
Altay
f73778e9b2 fix: remove redundant root strip-ansi dependency (#39652) 2026-03-08 12:04:46 +03:00
Nutchanon (Ben) Ninyawee
c1b914026d fix: add missing strip-ansi dep for pi-coding-agent (#38999)
Merged via squash.

Prepared head SHA: dd03a6aaaf
Co-authored-by: ninyawee <8089231+ninyawee@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 12:00:17 +03:00
Daniel Hnyk
9425209602 fix(mattermost): pass payload.replyToId as root_id for threaded replies (#27744)
Merged via squash.

Prepared head SHA: e029079872
Co-authored-by: hnykda <2741256+hnykda@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
2026-03-08 14:13:13 +05:30
Ayaan Zaidi
4db634964b chore(secrets): sync appcast baseline 2026-03-08 13:29:26 +05:30
Ayaan Zaidi
6477da623f chore(secrets): sync detect-secrets baseline 2026-03-08 13:25:01 +05:30
Ayaan Zaidi
d3c3d0e730 style(android): update app icon 2026-03-08 13:25:01 +05:30
Peter Lee
92648f9ba9 fix(agents): broaden 402 temporary-limit detection and allow billing cooldown probe (#38533)
Merged via squash.

Prepared head SHA: 282b9186c6
Co-authored-by: xialonglee <22994703+xialonglee@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-03-08 10:27:01 +03:00
Peter Steinberger
d15b6af77b fix: land contributor PR #39516 from @Imhermes1
macOS app/chat/browser/cron/permissions fixes.

Co-authored-by: ImHermes1 <lukeforn@gmail.com>
2026-03-08 06:11:20 +00:00
Peter Steinberger
05217845a7 build: bump version to 2026.3.8 2026-03-08 05:59:04 +00:00
Peter Steinberger
389647157d build: update stable appcast release URL 2026-03-08 05:53:19 +00:00
Ayaan Zaidi
c217237a36 style(daemon-cli): format lifecycle test 2026-03-08 11:22:57 +05:30
Peter Steinberger
42a1394c5c build: prepare 2026.3.7 release 2026-03-08 05:42:26 +00:00
Vincent Koc
c3810346f9 CLI: avoid false update restart failures without listener attribution (#39508) 2026-03-07 21:42:25 -08:00
Peter Steinberger
e0f80cf0e9 fix(ui): align control-ui device auth token signing 2026-03-08 05:41:03 +00:00
Peter Steinberger
5d22bd0297 fix: add google flash-lite forward compat 2026-03-08 05:22:38 +00:00
Peter Steinberger
59102a1ff7 fix: add gemini 3.1 flash-lite support 2026-03-08 05:12:48 +00:00
Peter Steinberger
06ffef8465 fix(ci): repair zalouser CI failures 2026-03-08 05:09:12 +00:00
Peter Steinberger
c6a8ab69c6 build: refresh beta appcast asset signature 2026-03-08 04:53:53 +00:00
Peter Steinberger
fcdc1a13e1 fix: land #33992 from @darkamenosa
Co-authored-by: Tom <hxtxmu@gmail.com>
2026-03-08 04:49:04 +00:00
309 changed files with 5041 additions and 1997 deletions

View File

@@ -179,6 +179,29 @@
"line_number": 15
}
],
"appcast.xml": [
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "7afea670e53d801f1f881c99c40aa177e3395bfa",
"is_verified": false,
"line_number": 365
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "6e1ba26139ac4e73427e68a7eec2abf96bcf1fd4",
"is_verified": false,
"line_number": 584
},
{
"type": "Base64 High Entropy String",
"filename": "appcast.xml",
"hashed_secret": "c0baa9660a8d3b11874c63a535d8369f4a8fa8fa",
"is_verified": false,
"line_number": 723
}
],
"apps/android/app/src/test/java/ai/openclaw/android/node/AppUpdateHandlerTest.kt": [
{
"type": "Hex High Entropy String",
@@ -9772,63 +9795,63 @@
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "1188d5a8ed7edcff5144a9472af960243eacf12e",
"is_verified": false,
"line_number": 1611
"line_number": 1612
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "bde4db9b4c3be4049adc3b9a69851d7c35119770",
"is_verified": false,
"line_number": 1627
"line_number": 1628
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "7f8aaf142ce0552c260f2e546dda43ddd7c9aef3",
"is_verified": false,
"line_number": 1812
"line_number": 1813
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "22af290a1a3d5e941193a41a3d3a9e4ca8da5e27",
"is_verified": false,
"line_number": 1985
"line_number": 1986
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 2041
"line_number": 2042
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "c1e6ee547fd492df1441ac492e8bb294974712bd",
"is_verified": false,
"line_number": 2273
"line_number": 2274
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2401
"line_number": 2402
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "a219d7693c25cd2d93313512e200ff3eb374d281",
"is_verified": false,
"line_number": 2654
"line_number": 2655
},
{
"type": "Secret Keyword",
"filename": "docs/gateway/configuration-reference.md",
"hashed_secret": "b6f56e5e92078ed7c078c46fbfeedcbe5719bc25",
"is_verified": false,
"line_number": 2656
"line_number": 2657
}
],
"docs/gateway/configuration.md": [
@@ -9922,7 +9945,7 @@
"filename": "docs/help/faq.md",
"hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6",
"is_verified": false,
"line_number": 2489
"line_number": 2490
}
],
"docs/install/macos-vm.md": [
@@ -10010,14 +10033,14 @@
"filename": "docs/providers/minimax.md",
"hashed_secret": "ec3810e10fb78db55ce38b9c18d1c3eb1db739e0",
"is_verified": false,
"line_number": 70
"line_number": 69
},
{
"type": "Secret Keyword",
"filename": "docs/providers/minimax.md",
"hashed_secret": "16c249e04e2be318050cb883c40137361c0c7209",
"is_verified": false,
"line_number": 149
"line_number": 148
}
],
"docs/providers/moonshot.md": [
@@ -11560,7 +11583,7 @@
"filename": "src/agents/pi-embedded-runner/model.ts",
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
"is_verified": false,
"line_number": 267
"line_number": 272
}
],
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
@@ -13011,5 +13034,5 @@
}
]
},
"generated_at": "2026-03-08T03:31:44Z"
"generated_at": "2026-03-08T05:05:36Z"
}

View File

@@ -2,7 +2,21 @@
Docs: https://docs.openclaw.ai
## 2026.3.7 (Unreleased)
## 2026.3.8
### Changes
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
### Fixes
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus.
- Agents/openai-codex model resolution: fall through from inline `openai-codex` model entries without an `api` so GPT-5.4 keeps the codex transport and still preserves configured `baseUrl` and headers. (#39753) Thanks @justinhuangcode.
## 2026.3.7
### Changes
@@ -32,6 +46,7 @@ Docs: https://docs.openclaw.ai
- iOS/App Store Connect release prep: align iOS bundle identifiers under `ai.openclaw.client`, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.
- Mattermost/model picker: add Telegram-style interactive provider/model browsing for `/oc_model` and `/oc_models`, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
- Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add `OPENCLAW_VARIANT=slim` build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
### Breaking
@@ -40,6 +55,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`.
- Models/Vercel AI Gateway: synthesize the built-in `vercel-ai-gateway` provider from `AI_GATEWAY_API_KEY` and auto-discover the live `/v1/models` catalog so `/models vercel-ai-gateway` exposes current refs including `openai/gpt-5.4`.
- Security/Config: fail closed when `loadConfig()` hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.
- Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in `bm25RankToScore()` so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.
- LINE/`requireMention` group gating: align inbound and reply-stage LINE group policy resolution across raw, `group:`, and `room:` keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.
@@ -352,6 +368,8 @@ Docs: https://docs.openclaw.ai
- Discord/config schema parity: add `channels.discord.agentComponents` to the strict Zod config schema so valid `agentComponents.enabled` settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
- ACPX/MCP session bootstrap: inject configured MCP servers into ACP `session/new` and `session/load` for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.
- Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of `You`. (#39414) Thanks @obviyus.
- Agents/failover 402 recovery: keep temporary spend-limit `402` payloads retryable, preserve explicit insufficient-credit billing detection even in long provider payloads, and allow throttled billing-cooldown probes so single-provider setups can recover instead of staying locked out. (#38533) Thanks @xialonglee.
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
## 2026.3.2
@@ -379,6 +397,7 @@ Docs: https://docs.openclaw.ai
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
- Agents/compaction safeguard quality-audit rollout: keep summary quality audits disabled by default unless `agents.defaults.compaction.qualityGuard` is explicitly enabled, and add config plumbing for bounded retry control. (#25556) thanks @rodrigouroz.
- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc.
- Zalo Personal plugin (`@openclaw/zalouser`): keep canonical DM routing while preserving legacy DM session continuity on upgrade, and preserve provider-native `g-`/`u-` target ids in outbound send and directory flows so #33992 lands without breaking existing sessions or stored targets. (#33992) Thanks @darkamenosa.
### Breaking
@@ -708,6 +727,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade.
- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting.
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -38,6 +38,7 @@
<li>iOS/App Store Connect release prep: align iOS bundle identifiers under <code>ai.openclaw.client</code>, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.</li>
<li>Mattermost/model picker: add Telegram-style interactive provider/model browsing for <code>/oc_model</code> and <code>/oc_models</code>, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.</li>
<li>Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add <code>OPENCLAW_VARIANT=slim</code> build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.</li>
<li>Google/Gemini 3.1 Flash-Lite: add first-class <code>google/gemini-3.1-flash-lite-preview</code> support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.</li>
</ul>
<h3>Breaking</h3>
<ul>
@@ -361,7 +362,7 @@
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7-beta.1/OpenClaw-2026.3.7.zip" length="23263824" type="application/octet-stream" sparkle:edSignature="i438TEno1c6NMOzgGSSYbGek6634BT6hLNe2Pl1A782kBdJxrxlxSQF0V3lrZKoWrQklQUoKqjOxqNJNdSv3CA=="/>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
</item>
<item>
<title>2026.3.2</title>

View File

@@ -211,7 +211,7 @@ What it does:
- Reads `node.describe` command list from the selected Android node.
- Invokes advertised non-interactive commands.
- Skips `screen.record` in this suite (Android requires interactive per-invocation screen-capture consent).
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send`, `notifications.actions`, `app.update`).
- Asserts command contracts (success or expected deterministic error for safe-invalid calls like `sms.send` and `notifications.actions`).
Common failure quick-fixes:

View File

@@ -63,8 +63,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 202603070
versionName = "2026.3.7"
versionCode = 202603081
versionName = "2026.3.8"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -3,15 +3,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
@@ -25,7 +22,6 @@
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
@@ -47,7 +43,7 @@
<service
android:name=".NodeForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
android:foregroundServiceType="dataSync" />
<service
android:name=".node.DeviceNotificationListenerService"
android:label="@string/app_name"
@@ -76,9 +72,5 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".InstallResultReceiver"
android:exported="false" />
</application>
</manifest>

View File

@@ -1,33 +0,0 @@
package ai.openclaw.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// System needs user confirmation — launch the confirmation activity
@Suppress("DEPRECATION")
val confirmIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (confirmIntent != null) {
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(confirmIntent)
Log.w("openclaw", "app.update: user confirmation requested, launching install dialog")
}
}
PackageInstaller.STATUS_SUCCESS -> {
Log.w("openclaw", "app.update: install SUCCESS")
}
else -> {
Log.e("openclaw", "app.update: install FAILED status=$status message=$message")
}
}
}
}

View File

@@ -3,12 +3,12 @@ package ai.openclaw.app
enum class LocationMode(val rawValue: String) {
Off("off"),
WhileUsing("whileUsing"),
Always("always"),
;
companion object {
fun fromRawValue(raw: String?): LocationMode {
val normalized = raw?.trim()?.lowercase()
if (normalized == "always") return WhileUsing
return entries.firstOrNull { it.rawValue.lowercase() == normalized } ?: Off
}
}

View File

@@ -18,18 +18,14 @@ import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var permissionRequester: PermissionRequester
private lateinit var screenCaptureRequester: ScreenCaptureRequester
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
permissionRequester = PermissionRequester(this)
screenCaptureRequester = ScreenCaptureRequester(this)
viewModel.camera.attachLifecycleOwner(this)
viewModel.camera.attachPermissionRequester(permissionRequester)
viewModel.sms.attachPermissionRequester(permissionRequester)
viewModel.screenRecorder.attachScreenCaptureRequester(screenCaptureRequester)
viewModel.screenRecorder.attachPermissionRequester(permissionRequester)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {

View File

@@ -6,7 +6,6 @@ import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.ScreenRecordManager
import ai.openclaw.app.node.SmsManager
import ai.openclaw.app.voice.VoiceConversationEntry
import kotlinx.coroutines.flow.StateFlow
@@ -20,7 +19,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
val camera: CameraCaptureManager = runtime.camera
val screenRecorder: ScreenRecordManager = runtime.screenRecorder
val sms: SmsManager = runtime.sms
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
@@ -38,7 +36,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
val screenRecordActive: StateFlow<Boolean> = runtime.screenRecordActive
val instanceId: StateFlow<String> = runtime.instanceId
val displayName: StateFlow<String> = runtime.displayName

View File

@@ -5,13 +5,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.PendingIntent
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -23,14 +20,13 @@ import kotlinx.coroutines.launch
class NodeForegroundService : Service() {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var notificationJob: Job? = null
private var lastRequiresMic = false
private var didStartForeground = false
override fun onCreate() {
super.onCreate()
ensureChannel()
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
startForegroundWithTypes(notification = initial, requiresMic = false)
startForegroundWithTypes(notification = initial)
val runtime = (application as NodeApp).runtime
notificationJob =
@@ -53,11 +49,8 @@ class NodeForegroundService : Service() {
}
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
val requiresMic =
micEnabled && hasRecordAudioPermission()
startForegroundWithTypes(
notification = buildNotification(title = title, text = text),
requiresMic = requiresMic,
)
}
}
@@ -135,30 +128,15 @@ class NodeForegroundService : Service() {
mgr.notify(NOTIFICATION_ID, notification)
}
private fun startForegroundWithTypes(notification: Notification, requiresMic: Boolean) {
if (didStartForeground && requiresMic == lastRequiresMic) {
private fun startForegroundWithTypes(notification: Notification) {
if (didStartForeground) {
updateNotification(notification)
return
}
lastRequiresMic = requiresMic
val types =
if (requiresMic) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
startForeground(NOTIFICATION_ID, notification, types)
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
didStartForeground = true
}
private fun hasRecordAudioPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
)
}
companion object {
private const val CHANNEL_ID = "connection"
private const val NOTIFICATION_ID = 1

View File

@@ -50,7 +50,6 @@ class NodeRuntime(context: Context) {
val canvas = CanvasController()
val camera = CameraCaptureManager(appContext)
val location = LocationCaptureManager(appContext)
val screenRecorder = ScreenRecordManager(appContext)
val sms = SmsManager(appContext)
private val json = Json { ignoreUnknownKeys = true }
@@ -77,17 +76,11 @@ class NodeRuntime(context: Context) {
identityStore = identityStore,
)
private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler(
appContext = appContext,
connectedEndpoint = { connectedEndpoint },
)
private val locationHandler: LocationHandler = LocationHandler(
appContext = appContext,
location = location,
json = json,
isForeground = { _isForeground.value },
locationMode = { locationMode.value },
locationPreciseEnabled = { locationPreciseEnabled.value },
)
@@ -119,12 +112,6 @@ class NodeRuntime(context: Context) {
appContext = appContext,
)
private val screenHandler: ScreenHandler = ScreenHandler(
screenRecorder = screenRecorder,
setScreenRecordActive = { _screenRecordActive.value = it },
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
)
private val smsHandlerImpl: SmsHandler = SmsHandler(
sms = sms,
)
@@ -159,11 +146,9 @@ class NodeRuntime(context: Context) {
contactsHandler = contactsHandler,
calendarHandler = calendarHandler,
motionHandler = motionHandler,
screenHandler = screenHandler,
smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler,
debugHandler = debugHandler,
appUpdateHandler = appUpdateHandler,
isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off },
@@ -206,9 +191,6 @@ class NodeRuntime(context: Context) {
private val _cameraFlashToken = MutableStateFlow(0L)
val cameraFlashToken: StateFlow<Long> = _cameraFlashToken.asStateFlow()
private val _screenRecordActive = MutableStateFlow(false)
val screenRecordActive: StateFlow<Boolean> = _screenRecordActive.asStateFlow()
private val _canvasA2uiHydrated = MutableStateFlow(false)
val canvasA2uiHydrated: StateFlow<Boolean> = _canvasA2uiHydrated.asStateFlow()
private val _canvasRehydratePending = MutableStateFlow(false)
@@ -623,6 +605,9 @@ class NodeRuntime(context: Context) {
fun setForeground(value: Boolean) {
_isForeground.value = value
if (!value) {
stopActiveVoiceSession()
}
}
fun setDisplayName(value: String) {
@@ -667,11 +652,7 @@ class NodeRuntime(context: Context) {
fun setVoiceScreenActive(active: Boolean) {
if (!active) {
// User left voice screen — stop mic and TTS
talkMode.ttsOnAllResponses = false
talkMode.stopTts()
micCapture.setMicEnabled(false)
prefs.setTalkEnabled(false)
stopActiveVoiceSession()
}
// Don't re-enable on active=true; mic toggle drives that
}
@@ -700,6 +681,14 @@ class NodeRuntime(context: Context) {
talkMode.setPlaybackEnabled(value)
}
private fun stopActiveVoiceSession() {
talkMode.ttsOnAllResponses = false
talkMode.stopTts()
micCapture.setMicEnabled(false)
prefs.setTalkEnabled(false)
externalAudioCaptureActive.value = false
}
fun refreshGatewayConnection() {
val endpoint =
connectedEndpoint ?: run {

View File

@@ -1,65 +0,0 @@
package ai.openclaw.app
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
class ScreenCaptureRequester(private val activity: ComponentActivity) {
data class CaptureResult(val resultCode: Int, val data: Intent)
private val mutex = Mutex()
private var pending: CompletableDeferred<CaptureResult?>? = null
private val launcher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val p = pending
pending = null
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
p?.complete(CaptureResult(result.resultCode, data))
} else {
p?.complete(null)
}
}
suspend fun requestCapture(timeoutMs: Long = 20_000): CaptureResult? =
mutex.withLock {
val proceed = showRationaleDialog()
if (!proceed) return null
val mgr = activity.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = mgr.createScreenCaptureIntent()
val deferred = CompletableDeferred<CaptureResult?>()
pending = deferred
withContext(Dispatchers.Main) { launcher.launch(intent) }
withContext(Dispatchers.Default) { withTimeout(timeoutMs) { deferred.await() } }
}
private suspend fun showRationaleDialog(): Boolean =
withContext(Dispatchers.Main) {
suspendCancellableCoroutine { cont ->
AlertDialog.Builder(activity)
.setTitle("Screen recording required")
.setMessage("OpenClaw needs to record the screen for this command.")
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
.setOnCancelListener { cont.resume(false) }
.show()
}
}
}

View File

@@ -19,6 +19,7 @@ class SecurePrefs(context: Context) {
companion object {
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
private const val displayNameKey = "node.displayName"
private const val locationModeKey = "location.enabledMode"
private const val voiceWakeModeKey = "voiceWake.mode"
private const val plainPrefsName = "openclaw.node"
private const val securePrefsName = "openclaw.node.secure"
@@ -46,8 +47,7 @@ class SecurePrefs(context: Context) {
private val _cameraEnabled = MutableStateFlow(plainPrefs.getBoolean("camera.enabled", true))
val cameraEnabled: StateFlow<Boolean> = _cameraEnabled
private val _locationMode =
MutableStateFlow(LocationMode.fromRawValue(plainPrefs.getString("location.enabledMode", "off")))
private val _locationMode = MutableStateFlow(loadLocationMode())
val locationMode: StateFlow<LocationMode> = _locationMode
private val _locationPreciseEnabled =
@@ -120,7 +120,7 @@ class SecurePrefs(context: Context) {
}
fun setLocationMode(mode: LocationMode) {
plainPrefs.edit { putString("location.enabledMode", mode.rawValue) }
plainPrefs.edit { putString(locationModeKey, mode.rawValue) }
_locationMode.value = mode
}
@@ -290,6 +290,15 @@ class SecurePrefs(context: Context) {
return resolved
}
private fun loadLocationMode(): LocationMode {
val raw = plainPrefs.getString(locationModeKey, "off")
val resolved = LocationMode.fromRawValue(raw)
if (raw?.trim()?.lowercase() == "always") {
plainPrefs.edit { putString(locationModeKey, resolved.rawValue) }
}
return resolved
}
private fun loadWakeWords(): List<String> {
val raw = plainPrefs.getString("voiceWake.triggerWords", null)?.trim()
if (raw.isNullOrEmpty()) return defaultWakeWords

View File

@@ -1,295 +0,0 @@
package ai.openclaw.app.node
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import ai.openclaw.app.InstallResultReceiver
import ai.openclaw.app.MainActivity
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewaySession
import java.io.File
import java.net.URI
import java.security.MessageDigest
import java.util.Locale
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$")
internal data class AppUpdateRequest(
val url: String,
val expectedSha256: String,
)
internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest {
val params =
try {
paramsJson?.let { Json.parseToJsonElement(it).jsonObject }
} catch (_: Throwable) {
throw IllegalArgumentException("params must be valid JSON")
} ?: throw IllegalArgumentException("missing 'url' parameter")
val urlRaw =
params["url"]?.jsonPrimitive?.content?.trim().orEmpty()
.ifEmpty { throw IllegalArgumentException("missing 'url' parameter") }
val sha256Raw =
params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty()
.ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") }
if (!SHA256_HEX.matches(sha256Raw)) {
throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)")
}
val uri =
try {
URI(urlRaw)
} catch (_: Throwable) {
throw IllegalArgumentException("invalid 'url' parameter")
}
val scheme = uri.scheme?.lowercase(Locale.US).orEmpty()
if (scheme != "https") {
throw IllegalArgumentException("url must use https")
}
if (!uri.userInfo.isNullOrBlank()) {
throw IllegalArgumentException("url must not include credentials")
}
val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required")
val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty()
if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) {
throw IllegalArgumentException("url host must match connected gateway host")
}
return AppUpdateRequest(
url = uri.toASCIIString(),
expectedSha256 = sha256Raw.lowercase(Locale.US),
)
}
internal fun sha256Hex(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
if (read == 0) continue
digest.update(buffer, 0, read)
}
}
val out = StringBuilder(64)
for (byte in digest.digest()) {
out.append(String.format(Locale.US, "%02x", byte))
}
return out.toString()
}
class AppUpdateHandler(
private val appContext: Context,
private val connectedEndpoint: () -> GatewayEndpoint?,
) {
fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult {
try {
val updateRequest =
try {
parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host)
} catch (err: IllegalArgumentException) {
return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}",
)
}
val url = updateRequest.url
val expectedSha256 = updateRequest.expectedSha256
android.util.Log.w("openclaw", "app.update: downloading from $url")
val notifId = 9001
val channelId = "app_update"
val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
// Create notification channel (required for Android 8+)
val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW)
notifManager.createNotificationChannel(channel)
// PendingIntent to open the app when notification is tapped
val launchIntent = Intent(appContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
// Launch download async so the invoke returns immediately
CoroutineScope(Dispatchers.IO).launch {
try {
val cacheDir = java.io.File(appContext.cacheDir, "updates")
cacheDir.mkdirs()
val file = java.io.File(cacheDir, "update.apk")
if (file.exists()) file.delete()
// Show initial progress notification
fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification {
return android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setContentTitle("OpenClaw Update")
.setContentText(text)
.setProgress(max, progress, max == 0)
.setContentIntent(launchPi)
.setOngoing(true)
.build()
}
notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting..."))
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(300, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder().url(url).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
notifManager.cancel(notifId)
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText("HTTP ${response.code}")
.build())
return@launch
}
val contentLength = response.body?.contentLength() ?: -1L
val body = response.body ?: run {
notifManager.cancel(notifId)
return@launch
}
// Download with progress tracking
var totalBytes = 0L
var lastNotifUpdate = 0L
body.byteStream().use { input ->
file.outputStream().use { output ->
val buffer = ByteArray(8192)
while (true) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
totalBytes += bytesRead
// Update notification at most every 500ms
val now = System.currentTimeMillis()
if (now - lastNotifUpdate > 500) {
lastNotifUpdate = now
if (contentLength > 0) {
val pct = ((totalBytes * 100) / contentLength).toInt()
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0)
notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
} else {
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
}
}
}
}
}
android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes")
val actualSha256 = sha256Hex(file)
if (actualSha256 != expectedSha256) {
android.util.Log.e(
"openclaw",
"app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256",
)
file.delete()
notifManager.cancel(notifId)
notifManager.notify(
notifId,
android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText("SHA-256 mismatch")
.build(),
)
return@launch
}
// Verify file is a valid APK (basic check: ZIP magic bytes)
val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() }
if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) {
android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})")
file.delete()
notifManager.cancel(notifId)
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText("Downloaded file is not a valid APK")
.build())
return@launch
}
// Use PackageInstaller session API — works from background on API 34+
// The system handles showing the install confirmation dialog
notifManager.cancel(notifId)
notifManager.notify(
notifId,
android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("Installing Update...")
.setContentIntent(launchPi)
.setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded")
.build(),
)
val installer = appContext.packageManager.packageInstaller
val params = android.content.pm.PackageInstaller.SessionParams(
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
params.setSize(file.length())
val sessionId = installer.createSession(params)
val session = installer.openSession(sessionId)
session.openWrite("openclaw-update.apk", 0, file.length()).use { out ->
file.inputStream().use { inp -> inp.copyTo(out) }
session.fsync(out)
}
// Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status
val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java)
val pi = android.app.PendingIntent.getBroadcast(
appContext, sessionId, callbackIntent,
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)
android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation")
} catch (err: Throwable) {
android.util.Log.e("openclaw", "app.update: async error", err)
notifManager.cancel(notifId)
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("Update Failed")
.setContentIntent(launchPi)
.setContentText(err.message ?: "Unknown error")
.build())
}
}
// Return immediately — download happens in background
return GatewaySession.InvokeResult.ok(buildJsonObject {
put("status", "downloading")
put("url", url)
put("sha256", expectedSha256)
}.toString())
} catch (err: Throwable) {
android.util.Log.e("openclaw", "app.update: error", err)
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed")
}
}
}

View File

@@ -170,13 +170,6 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
put(
"backgroundLocation",
permissionStateJson(
granted = hasPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
promptableWhenDenied = true,
),
)
put(
"sms",
permissionStateJson(
@@ -226,14 +219,6 @@ class DeviceHandler(
promptableWhenDenied = true,
),
)
// Screen capture on Android is interactive per-capture consent, not a sticky app permission.
put(
"screenCapture",
permissionStateJson(
granted = false,
promptableWhenDenied = true,
),
)
},
)
}.toString()

View File

@@ -11,7 +11,6 @@ import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawPhotosCommand
import ai.openclaw.app.protocol.OpenClawScreenCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
@@ -59,11 +58,9 @@ object InvokeCommandRegistry {
val capabilityManifest: List<NodeCapabilitySpec> =
listOf(
NodeCapabilitySpec(name = OpenClawCapability.Canvas.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Screen.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Device.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Notifications.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.System.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.AppUpdate.rawValue),
NodeCapabilitySpec(
name = OpenClawCapability.Camera.rawValue,
availability = NodeCapabilityAvailability.CameraEnabled,
@@ -123,10 +120,6 @@ object InvokeCommandRegistry {
name = OpenClawCanvasA2UICommand.Reset.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawScreenCommand.Record.rawValue,
requiresForeground = true,
),
InvokeCommandSpec(
name = OpenClawSystemCommand.Notify.rawValue,
),
@@ -202,7 +195,6 @@ object InvokeCommandRegistry {
name = "debug.ed25519",
availability = InvokeCommandAvailability.DebugBuild,
),
InvokeCommandSpec(name = "app.update"),
)
private val byNameInternal: Map<String, InvokeCommandSpec> = all.associateBy { it.name }

View File

@@ -10,7 +10,6 @@ import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand
import ai.openclaw.app.protocol.OpenClawMotionCommand
import ai.openclaw.app.protocol.OpenClawNotificationsCommand
import ai.openclaw.app.protocol.OpenClawScreenCommand
import ai.openclaw.app.protocol.OpenClawSmsCommand
import ai.openclaw.app.protocol.OpenClawSystemCommand
@@ -25,11 +24,9 @@ class InvokeDispatcher(
private val contactsHandler: ContactsHandler,
private val calendarHandler: CalendarHandler,
private val motionHandler: MotionHandler,
private val screenHandler: ScreenHandler,
private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler,
private val appUpdateHandler: AppUpdateHandler,
private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean,
@@ -161,19 +158,12 @@ class InvokeDispatcher(
OpenClawMotionCommand.Activity.rawValue -> motionHandler.handleMotionActivity(paramsJson)
OpenClawMotionCommand.Pedometer.rawValue -> motionHandler.handleMotionPedometer(paramsJson)
// Screen command
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
// SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
// Debug commands
"debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs()
// App update
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
else -> GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = "INVALID_REQUEST: unknown command")
}
}

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.pm.PackageManager
import android.location.LocationManager
import androidx.core.content.ContextCompat
import ai.openclaw.app.LocationMode
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.serialization.json.Json
@@ -17,7 +16,6 @@ class LocationHandler(
private val location: LocationCaptureManager,
private val json: Json,
private val isForeground: () -> Boolean,
private val locationMode: () -> LocationMode,
private val locationPreciseEnabled: () -> Boolean,
) {
fun hasFineLocationPermission(): Boolean {
@@ -34,19 +32,11 @@ class LocationHandler(
)
}
fun hasBackgroundLocationPermission(): Boolean {
return (
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
)
}
suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
val mode = locationMode()
if (!isForeground() && mode != LocationMode.Always) {
if (!isForeground()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_BACKGROUND_UNAVAILABLE",
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open",
)
}
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
@@ -55,12 +45,6 @@ class LocationHandler(
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
)
}
if (!isForeground() && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
return GatewaySession.InvokeResult.error(
code = "LOCATION_PERMISSION_REQUIRED",
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
)
}
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
val preciseEnabled = locationPreciseEnabled()
val accuracy =

View File

@@ -1,25 +0,0 @@
package ai.openclaw.app.node
import ai.openclaw.app.gateway.GatewaySession
class ScreenHandler(
private val screenRecorder: ScreenRecordManager,
private val setScreenRecordActive: (Boolean) -> Unit,
private val invokeErrorFromThrowable: (Throwable) -> Pair<String, String>,
) {
suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult {
setScreenRecordActive(true)
try {
val res =
try {
screenRecorder.record(paramsJson)
} catch (err: Throwable) {
val (code, message) = invokeErrorFromThrowable(err)
return GatewaySession.InvokeResult.error(code = code, message = message)
}
return GatewaySession.InvokeResult.ok(res.payloadJson)
} finally {
setScreenRecordActive(false)
}
}
}

View File

@@ -1,165 +0,0 @@
package ai.openclaw.app.node
import android.content.Context
import android.hardware.display.DisplayManager
import android.media.MediaRecorder
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.util.Base64
import ai.openclaw.app.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import java.io.File
import kotlin.math.roundToInt
class ScreenRecordManager(private val context: Context) {
data class Payload(val payloadJson: String)
@Volatile private var screenCaptureRequester: ScreenCaptureRequester? = null
@Volatile private var permissionRequester: ai.openclaw.app.PermissionRequester? = null
fun attachScreenCaptureRequester(requester: ScreenCaptureRequester) {
screenCaptureRequester = requester
}
fun attachPermissionRequester(requester: ai.openclaw.app.PermissionRequester) {
permissionRequester = requester
}
suspend fun record(paramsJson: String?): Payload =
withContext(Dispatchers.Default) {
val requester =
screenCaptureRequester
?: throw IllegalStateException(
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val params = parseJsonParamsObject(paramsJson)
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
val fpsInt = fps.roundToInt().coerceIn(1, 60)
val screenIndex = parseScreenIndex(params)
val includeAudio = parseIncludeAudio(params) ?: true
val format = parseString(params, key = "format")
if (format != null && format.lowercase() != "mp4") {
throw IllegalArgumentException("INVALID_REQUEST: screen format must be mp4")
}
if (screenIndex != null && screenIndex != 0) {
throw IllegalArgumentException("INVALID_REQUEST: screenIndex must be 0 on Android")
}
val capture = requester.requestCapture()
?: throw IllegalStateException(
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
val mgr =
context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val projection = mgr.getMediaProjection(capture.resultCode, capture.data)
?: throw IllegalStateException("UNAVAILABLE: screen capture unavailable")
val metrics = context.resources.displayMetrics
val width = metrics.widthPixels
val height = metrics.heightPixels
val densityDpi = metrics.densityDpi
val file = File.createTempFile("openclaw-screen-", ".mp4")
if (includeAudio) ensureMicPermission()
val recorder = createMediaRecorder()
var virtualDisplay: android.hardware.display.VirtualDisplay? = null
try {
if (includeAudio) {
recorder.setAudioSource(MediaRecorder.AudioSource.MIC)
}
recorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
recorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
if (includeAudio) {
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
recorder.setAudioChannels(1)
recorder.setAudioSamplingRate(44_100)
recorder.setAudioEncodingBitRate(96_000)
}
recorder.setVideoSize(width, height)
recorder.setVideoFrameRate(fpsInt)
recorder.setVideoEncodingBitRate(estimateBitrate(width, height, fpsInt))
recorder.setOutputFile(file.absolutePath)
recorder.prepare()
val surface = recorder.surface
virtualDisplay =
projection.createVirtualDisplay(
"openclaw-screen",
width,
height,
densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
surface,
null,
null,
)
recorder.start()
delay(durationMs.toLong())
} finally {
try {
recorder.stop()
} catch (_: Throwable) {
// ignore
}
recorder.reset()
recorder.release()
virtualDisplay?.release()
projection.stop()
}
val bytes = withContext(Dispatchers.IO) { file.readBytes() }
file.delete()
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
Payload(
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"fps":$fpsInt,"screenIndex":0,"hasAudio":$includeAudio}""",
)
}
private fun createMediaRecorder(): MediaRecorder = MediaRecorder(context)
private suspend fun ensureMicPermission() {
val granted =
androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) return
val requester =
permissionRequester
?: throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
val results = requester.requestIfMissing(listOf(android.Manifest.permission.RECORD_AUDIO))
if (results[android.Manifest.permission.RECORD_AUDIO] != true) {
throw IllegalStateException("MIC_PERMISSION_REQUIRED: grant Microphone permission")
}
}
private fun parseDurationMs(params: JsonObject?): Int? =
parseJsonInt(params, "durationMs")
private fun parseFps(params: JsonObject?): Double? =
parseJsonDouble(params, "fps")
private fun parseScreenIndex(params: JsonObject?): Int? =
parseJsonInt(params, "screenIndex")
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseString(params: JsonObject?, key: String): String? =
parseJsonString(params, key)
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
val pixels = width.toLong() * height.toLong()
val raw = (pixels * fps.toLong() * 2L).toInt()
return raw.coerceIn(1_000_000, 12_000_000)
}
}

View File

@@ -3,14 +3,12 @@ package ai.openclaw.app.protocol
enum class OpenClawCapability(val rawValue: String) {
Canvas("canvas"),
Camera("camera"),
Screen("screen"),
Sms("sms"),
VoiceWake("voiceWake"),
Location("location"),
Device("device"),
Notifications("notifications"),
System("system"),
AppUpdate("appUpdate"),
Photos("photos"),
Contacts("contacts"),
Calendar("calendar"),
@@ -52,15 +50,6 @@ enum class OpenClawCameraCommand(val rawValue: String) {
}
}
enum class OpenClawScreenCommand(val rawValue: String) {
Record("screen.record"),
;
companion object {
const val NamespacePrefix: String = "screen."
}
}
enum class OpenClawSmsCommand(val rawValue: String) {
Send("sms.send"),
;

View File

@@ -80,7 +80,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -118,7 +117,6 @@ private enum class PermissionToggle {
private enum class SpecialAccessToggle {
NotificationListener,
AppUpdates,
}
private val onboardingBackgroundGradient =
@@ -274,10 +272,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable {
mutableStateOf(isNotificationListenerEnabled(context))
}
var enableAppUpdates by
rememberSaveable {
mutableStateOf(canInstallUnknownApps(context))
}
var enableMicrophone by rememberSaveable { mutableStateOf(false) }
var enableCamera by rememberSaveable { mutableStateOf(false) }
var enablePhotos by rememberSaveable { mutableStateOf(false) }
@@ -342,7 +336,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
when (toggle) {
SpecialAccessToggle.NotificationListener -> enableNotificationListener = enabled
SpecialAccessToggle.AppUpdates -> enableAppUpdates = enabled
}
}
@@ -352,7 +345,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableLocation,
enableNotifications,
enableNotificationListener,
enableAppUpdates,
enableMicrophone,
enableCamera,
enablePhotos,
@@ -368,7 +360,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableLocation) enabled += "Location"
if (enableNotifications) enabled += "Notifications"
if (enableNotificationListener) enabled += "Notification listener"
if (enableAppUpdates) enabled += "App updates"
if (enableMicrophone) enabled += "Microphone"
if (enableCamera) enabled += "Camera"
if (enablePhotos) enabled += "Photos"
@@ -385,10 +376,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
openNotificationListenerSettings(context)
openedSpecialSetup = true
}
if (enableAppUpdates && !canInstallUnknownApps(context)) {
openUnknownAppSourcesSettings(context)
openedSpecialSetup = true
}
if (openedSpecialSetup) {
return@proceed
}
@@ -431,7 +418,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val grantedNow =
when (toggle) {
SpecialAccessToggle.NotificationListener -> isNotificationListenerEnabled(context)
SpecialAccessToggle.AppUpdates -> canInstallUnknownApps(context)
}
if (grantedNow) {
setSpecialAccessToggleEnabled(toggle, true)
@@ -441,7 +427,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
pendingSpecialAccessToggle = toggle
when (toggle) {
SpecialAccessToggle.NotificationListener -> openNotificationListenerSettings(context)
SpecialAccessToggle.AppUpdates -> openUnknownAppSourcesSettings(context)
}
}
@@ -459,13 +444,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
)
pendingSpecialAccessToggle = null
}
SpecialAccessToggle.AppUpdates -> {
setSpecialAccessToggleEnabled(
SpecialAccessToggle.AppUpdates,
canInstallUnknownApps(context),
)
pendingSpecialAccessToggle = null
}
null -> Unit
}
}
@@ -606,7 +584,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableLocation = enableLocation,
enableNotifications = enableNotifications,
enableNotificationListener = enableNotificationListener,
enableAppUpdates = enableAppUpdates,
enableMicrophone = enableMicrophone,
enableCamera = enableCamera,
enablePhotos = enablePhotos,
@@ -649,9 +626,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onNotificationListenerChange = { checked ->
requestSpecialAccessToggle(SpecialAccessToggle.NotificationListener, checked)
},
onAppUpdatesChange = { checked ->
requestSpecialAccessToggle(SpecialAccessToggle.AppUpdates, checked)
},
onMicrophoneChange = { checked ->
requestPermissionToggle(
PermissionToggle.Microphone,
@@ -1337,7 +1311,6 @@ private fun PermissionsStep(
enableLocation: Boolean,
enableNotifications: Boolean,
enableNotificationListener: Boolean,
enableAppUpdates: Boolean,
enableMicrophone: Boolean,
enableCamera: Boolean,
enablePhotos: Boolean,
@@ -1353,7 +1326,6 @@ private fun PermissionsStep(
onLocationChange: (Boolean) -> Unit,
onNotificationsChange: (Boolean) -> Unit,
onNotificationListenerChange: (Boolean) -> Unit,
onAppUpdatesChange: (Boolean) -> Unit,
onMicrophoneChange: (Boolean) -> Unit,
onCameraChange: (Boolean) -> Unit,
onPhotosChange: (Boolean) -> Unit,
@@ -1387,7 +1359,6 @@ private fun PermissionsStep(
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
}
val notificationListenerGranted = isNotificationListenerEnabled(context)
val appUpdatesGranted = canInstallUnknownApps(context)
StepShell(title = "Permissions") {
Text(
@@ -1405,7 +1376,7 @@ private fun PermissionsStep(
InlineDivider()
PermissionToggleRow(
title = "Location",
subtitle = "location.get (while app is open unless set to Always later)",
subtitle = "location.get (while app is open)",
checked = enableLocation,
granted = locationGranted,
onCheckedChange = onLocationChange,
@@ -1429,17 +1400,9 @@ private fun PermissionsStep(
onCheckedChange = onNotificationListenerChange,
)
InlineDivider()
PermissionToggleRow(
title = "App updates",
subtitle = "app.update install confirmation (opens Android Settings)",
checked = enableAppUpdates,
granted = appUpdatesGranted,
onCheckedChange = onAppUpdatesChange,
)
InlineDivider()
PermissionToggleRow(
title = "Microphone",
subtitle = "Voice tab transcription",
subtitle = "Foreground Voice tab transcription",
checked = enableMicrophone,
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
onCheckedChange = onMicrophoneChange,
@@ -1635,10 +1598,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
return DeviceNotificationListenerService.isAccessEnabled(context)
}
private fun canInstallUnknownApps(context: Context): Boolean {
return context.packageManager.canRequestPackageInstalls()
}
private fun openNotificationListenerSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching {
@@ -1648,19 +1607,6 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
private fun openUnknownAppSourcesSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching {
context.startActivity(intent)
}.getOrElse {
openAppSettings(context)
}
}
private fun openAppSettings(context: Context) {
val intent =
Intent(

View File

@@ -62,7 +62,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
@@ -115,7 +114,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
viewModel.setCameraEnabled(cameraOk)
}
var pendingLocationMode by remember { mutableStateOf<LocationMode?>(null) }
var pendingLocationRequest by remember { mutableStateOf(false) }
var pendingPreciseToggle by remember { mutableStateOf(false) }
val locationPermissionLauncher =
@@ -123,8 +122,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
val fineOk = perms[Manifest.permission.ACCESS_FINE_LOCATION] == true
val coarseOk = perms[Manifest.permission.ACCESS_COARSE_LOCATION] == true
val granted = fineOk || coarseOk
val requestedMode = pendingLocationMode
pendingLocationMode = null
if (pendingPreciseToggle) {
pendingPreciseToggle = false
@@ -132,21 +129,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
return@rememberLauncherForActivityResult
}
if (!granted) {
viewModel.setLocationMode(LocationMode.Off)
return@rememberLauncherForActivityResult
}
if (requestedMode != null) {
viewModel.setLocationMode(requestedMode)
if (requestedMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
if (pendingLocationRequest) {
pendingLocationRequest = false
viewModel.setLocationMode(if (granted) LocationMode.WhileUsing else LocationMode.Off)
}
}
@@ -246,11 +231,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
motionPermissionGranted = granted
}
var appUpdateInstallEnabled by
remember {
mutableStateOf(canInstallUnknownApps(context))
}
var smsPermissionGranted by
remember {
mutableStateOf(
@@ -290,7 +270,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
!motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
PackageManager.PERMISSION_GRANTED
appUpdateInstallEnabled = canInstallUnknownApps(context)
smsPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) ==
PackageManager.PERMISSION_GRANTED
@@ -316,7 +295,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
}
fun requestLocationPermissions(targetMode: LocationMode) {
fun requestLocationPermissions() {
val fineOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
@@ -324,17 +303,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (fineOk || coarseOk) {
viewModel.setLocationMode(targetMode)
if (targetMode == LocationMode.Always) {
val backgroundOk =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (!backgroundOk) {
openAppSettings(context)
}
}
viewModel.setLocationMode(LocationMode.WhileUsing)
} else {
pendingLocationMode = targetMode
pendingLocationRequest = true
locationPermissionLauncher.launch(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
)
@@ -431,9 +402,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
supportingContent = {
Text(
if (micPermissionGranted) {
"Granted. Use the Voice tab mic button to capture transcript."
"Granted. Use the Voice tab mic button to capture transcript while the app is open."
} else {
"Required for Voice tab transcription."
"Required for foreground Voice tab transcription."
},
style = mobileCallout,
)
@@ -460,7 +431,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item {
Text(
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab.",
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.",
style = mobileCallout,
color = mobileTextSecondary,
)
@@ -759,41 +730,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
}
item { HorizontalDivider(color = mobileBorder) }
// System
item {
Text(
"SYSTEM",
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
color = mobileAccent,
)
}
item {
ListItem(
modifier = Modifier.settingsRowModifier(),
colors = listItemColors,
headlineContent = { Text("Install App Updates", style = mobileHeadline) },
supportingContent = {
Text(
"Enable install access for `app.update` package installs.",
style = mobileCallout,
)
},
trailingContent = {
Button(
onClick = { openUnknownAppSourcesSettings(context) },
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (appUpdateInstallEnabled) "Manage" else "Enable",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
}
item { HorizontalDivider(color = mobileBorder) }
// Location
item {
Text(
@@ -825,20 +761,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.WhileUsing,
onClick = { requestLocationPermissions(LocationMode.WhileUsing) },
)
},
)
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Always", style = mobileHeadline) },
supportingContent = { Text("Allow background location (requires system permission).", style = mobileCallout) },
trailingContent = {
RadioButton(
selected = locationMode == LocationMode.Always,
onClick = { requestLocationPermissions(LocationMode.Always) },
onClick = { requestLocationPermissions() },
)
},
)
@@ -858,14 +781,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
)
}
}
item {
Text(
"Always may require Android Settings to allow background location.",
style = mobileCallout,
color = mobileTextSecondary,
)
}
item { HorizontalDivider(color = mobileBorder) }
// Screen
@@ -970,19 +885,6 @@ private fun openNotificationListenerSettings(context: Context) {
}
}
private fun openUnknownAppSourcesSettings(context: Context) {
val intent =
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
)
runCatching {
context.startActivity(intent)
}.getOrElse {
openAppSettings(context)
}
}
private fun hasNotificationsPermission(context: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
@@ -993,10 +895,6 @@ private fun isNotificationListenerEnabled(context: Context): Boolean {
return DeviceNotificationListenerService.isAccessEnabled(context)
}
private fun canInstallUnknownApps(context: Context): Boolean {
return context.packageManager.canRequestPackageInstalls()
}
private fun hasMotionCapabilities(context: Context): Boolean {
val sensorManager = context.getSystemService(SensorManager::class.java) ?: return false
return sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) != null ||

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,3 +1,3 @@
<resources>
<color name="ic_launcher_background">#0A0A0A</color>
<color name="ic_launcher_background">#DD1A08</color>
</resources>

View File

@@ -0,0 +1,23 @@
package ai.openclaw.app
import android.content.Context
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class SecurePrefsTest {
@Test
fun loadLocationMode_migratesLegacyAlwaysValue() {
val context = RuntimeEnvironment.getApplication()
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
plainPrefs.edit().clear().putString("location.enabledMode", "always").commit()
val prefs = SecurePrefs(context)
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
}
}

View File

@@ -1,65 +0,0 @@
package ai.openclaw.app.node
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class AppUpdateHandlerTest {
@Test
fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() {
val req =
parseAppUpdateRequest(
paramsJson =
"""{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
connectedHost = "gw.example.com",
)
assertEquals("https://gw.example.com/releases/openclaw.apk", req.url)
assertEquals("a".repeat(64), req.expectedSha256)
}
@Test
fun parseAppUpdateRequest_rejectsNonHttps() {
assertThrows(IllegalArgumentException::class.java) {
parseAppUpdateRequest(
paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
connectedHost = "gw.example.com",
)
}
}
@Test
fun parseAppUpdateRequest_rejectsHostMismatch() {
assertThrows(IllegalArgumentException::class.java) {
parseAppUpdateRequest(
paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
connectedHost = "gw.example.com",
)
}
}
@Test
fun parseAppUpdateRequest_rejectsInvalidSha256() {
assertThrows(IllegalArgumentException::class.java) {
parseAppUpdateRequest(
paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""",
connectedHost = "gw.example.com",
)
}
}
@Test
fun sha256Hex_computesExpectedDigest() {
val tmp = File.createTempFile("openclaw-update-hash", ".bin")
try {
tmp.writeText("hello", Charsets.UTF_8)
assertEquals(
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", // pragma: allowlist secret
sha256Hex(tmp),
)
} finally {
tmp.delete()
}
}
}

View File

@@ -87,7 +87,6 @@ class DeviceHandlerTest {
"camera",
"microphone",
"location",
"backgroundLocation",
"sms",
"notificationListener",
"notifications",
@@ -95,7 +94,6 @@ class DeviceHandlerTest {
"contacts",
"calendar",
"motion",
"screenCapture",
)
for (key in expected) {
val state = permissions.getValue(key).jsonObject

View File

@@ -19,11 +19,9 @@ 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,
@@ -52,7 +50,6 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue,
"app.update",
)
private val optionalCommands =

View File

@@ -24,14 +24,12 @@ class OpenClawProtocolConstantsTest {
fun capabilitiesUseStableStrings() {
assertEquals("canvas", OpenClawCapability.Canvas.rawValue)
assertEquals("camera", OpenClawCapability.Camera.rawValue)
assertEquals("screen", OpenClawCapability.Screen.rawValue)
assertEquals("voiceWake", OpenClawCapability.VoiceWake.rawValue)
assertEquals("location", OpenClawCapability.Location.rawValue)
assertEquals("sms", OpenClawCapability.Sms.rawValue)
assertEquals("device", OpenClawCapability.Device.rawValue)
assertEquals("notifications", OpenClawCapability.Notifications.rawValue)
assertEquals("system", OpenClawCapability.System.rawValue)
assertEquals("appUpdate", OpenClawCapability.AppUpdate.rawValue)
assertEquals("photos", OpenClawCapability.Photos.rawValue)
assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
@@ -45,11 +43,6 @@ class OpenClawProtocolConstantsTest {
assertEquals("camera.clip", OpenClawCameraCommand.Clip.rawValue)
}
@Test
fun screenCommandsUseStableStrings() {
assertEquals("screen.record", OpenClawScreenCommand.Record.rawValue)
}
@Test
fun notificationsCommandsUseStableStrings() {
assertEquals("notifications.list", OpenClawNotificationsCommand.List.rawValue)

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -23,7 +23,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -36,7 +36,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
</dict>
</plist>

View File

@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>

View File

@@ -15,9 +15,9 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleVersion</key>
<string>20260307</string>
<string>20260308</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -98,8 +98,8 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -156,8 +156,8 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
@@ -193,8 +193,8 @@ targets:
path: ActivityWidget/Info.plist
properties:
CFBundleDisplayName: OpenClaw Activity
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"
NSSupportsLiveActivities: true
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
@@ -219,8 +219,8 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -244,8 +244,8 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
@@ -279,5 +279,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.3.7"
CFBundleVersion: "20260307"
CFBundleShortVersionString: "2026.3.8"
CFBundleVersion: "20260308"

View File

@@ -6,14 +6,14 @@ import OpenClawKit
import OSLog
actor CameraCaptureService {
struct CameraDeviceInfo: Encodable, Sendable {
struct CameraDeviceInfo: Encodable {
let id: String
let name: String
let position: String
let deviceType: String
}
enum CameraError: LocalizedError, Sendable {
enum CameraError: LocalizedError {
case cameraUnavailable
case microphoneUnavailable
case permissionDenied(kind: String)

View File

@@ -2,7 +2,7 @@ import Foundation
import OpenClawProtocol
enum ConfigStore {
struct Overrides: Sendable {
struct Overrides {
var isRemoteMode: (@Sendable () async -> Bool)?
var loadLocal: (@MainActor @Sendable () -> [String: Any])?
var saveLocal: (@MainActor @Sendable ([String: Any]) -> Void)?

View File

@@ -1,13 +1,13 @@
import Foundation
enum EffectiveConnectionModeSource: Sendable, Equatable {
enum EffectiveConnectionModeSource: Equatable {
case configMode
case configRemoteURL
case userDefaults
case onboarding
}
struct EffectiveConnectionMode: Sendable, Equatable {
struct EffectiveConnectionMode: Equatable {
let mode: AppState.ConnectionMode
let source: EffectiveConnectionModeSource
}

View File

@@ -14,7 +14,7 @@ struct ControlHeartbeatEvent: Codable {
let reason: String?
}
struct ControlAgentEvent: Codable, Sendable, Identifiable {
struct ControlAgentEvent: Codable, Identifiable {
var id: String {
"\(self.runId)-\(self.seq)"
}

View File

@@ -226,7 +226,7 @@ struct CronJob: Identifiable, Codable, Equatable {
}
}
struct CronEvent: Codable, Sendable {
struct CronEvent: Codable {
let jobId: String
let action: String
let runAtMs: Int?
@@ -237,7 +237,7 @@ struct CronEvent: Codable, Sendable {
let nextRunAtMs: Int?
}
struct CronRunLogEntry: Codable, Identifiable, Sendable {
struct CronRunLogEntry: Codable, Identifiable {
var id: String {
"\(self.jobId)-\(self.ts)"
}

View File

@@ -1,6 +1,6 @@
import Foundation
struct DevicePresentation: Sendable {
struct DevicePresentation {
let title: String
let symbol: String?
}

View File

@@ -7,7 +7,7 @@ actor DiagnosticsFileLog {
private let maxBytes: Int64 = 5 * 1024 * 1024
private let maxBackups = 5
struct Record: Codable, Sendable {
struct Record: Codable {
let ts: String
let pid: Int32
let category: String

View File

@@ -84,13 +84,13 @@ enum ExecAsk: String, CaseIterable, Codable, Identifiable {
}
}
enum ExecApprovalDecision: String, Codable, Sendable {
enum ExecApprovalDecision: String, Codable {
case allowOnce = "allow-once"
case allowAlways = "allow-always"
case deny
}
enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable {
enum ExecAllowlistPatternValidationReason: String, Codable, Equatable {
case empty
case missingPathComponent
@@ -104,12 +104,12 @@ enum ExecAllowlistPatternValidationReason: String, Codable, Sendable, Equatable
}
}
enum ExecAllowlistPatternValidation: Sendable, Equatable {
enum ExecAllowlistPatternValidation: Equatable {
case valid(String)
case invalid(ExecAllowlistPatternValidationReason)
}
struct ExecAllowlistRejectedEntry: Sendable, Equatable {
struct ExecAllowlistRejectedEntry: Equatable {
let id: UUID
let pattern: String
let reason: ExecAllowlistPatternValidationReason
@@ -753,7 +753,7 @@ enum ExecApprovalHelpers {
}
}
struct ExecEventPayload: Codable, Sendable {
struct ExecEventPayload: Codable {
var sessionKey: String
var runId: String
var host: String

View File

@@ -11,7 +11,7 @@ final class ExecApprovalsGatewayPrompter {
private let logger = Logger(subsystem: "ai.openclaw", category: "exec-approvals.gateway")
private var task: Task<Void, Never>?
struct GatewayApprovalRequest: Codable, Sendable {
struct GatewayApprovalRequest: Codable {
var id: String
var request: ExecApprovalPromptRequest
var createdAtMs: Int

View File

@@ -5,7 +5,7 @@ import Foundation
import OpenClawKit
import OSLog
struct ExecApprovalPromptRequest: Codable, Sendable {
struct ExecApprovalPromptRequest: Codable {
var command: String
var cwd: String?
var host: String?

View File

@@ -1,6 +1,6 @@
import Foundation
struct ExecCommandResolution: Sendable {
struct ExecCommandResolution {
let rawExecutable: String
let resolvedPath: String?
let executableName: String

View File

@@ -6,7 +6,7 @@ import OSLog
private let gatewayConnectionLogger = Logger(subsystem: "ai.openclaw", category: "gateway.connection")
enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
enum GatewayAgentChannel: String, Codable, CaseIterable {
case last
case whatsapp
case telegram
@@ -33,7 +33,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
}
}
struct GatewayAgentInvocation: Sendable {
struct GatewayAgentInvocation {
var message: String
var sessionKey: String = "main"
var thinking: String?
@@ -53,7 +53,7 @@ actor GatewayConnection {
typealias Config = (url: URL, token: String?, password: String?)
enum Method: String, Sendable {
enum Method: String {
case agent
case status
case setHeartbeats = "set-heartbeats"
@@ -110,6 +110,44 @@ actor GatewayConnection {
private var subscribers: [UUID: AsyncStream<GatewayPush>.Continuation] = [:]
private var lastSnapshot: HelloOk?
private struct LossyDecodable<Value: Decodable>: Decodable {
let value: Value?
init(from decoder: Decoder) throws {
do {
self.value = try Value(from: decoder)
} catch {
self.value = nil
}
}
}
private struct LossyCronListResponse: Decodable {
let jobs: [LossyDecodable<CronJob>]
enum CodingKeys: String, CodingKey {
case jobs
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try container.decodeIfPresent([LossyDecodable<CronJob>].self, forKey: .jobs) ?? []
}
}
private struct LossyCronRunsResponse: Decodable {
let entries: [LossyDecodable<CronRunLogEntry>]
enum CodingKeys: String, CodingKey {
case entries
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.entries = try container.decodeIfPresent([LossyDecodable<CronRunLogEntry>].self, forKey: .entries) ?? []
}
}
init(
configProvider: @escaping @Sendable () async throws -> Config = GatewayConnection.defaultConfigProvider,
sessionBox: WebSocketSessionBox? = nil)
@@ -390,9 +428,9 @@ actor GatewayConnection {
// MARK: - Typed gateway API
extension GatewayConnection {
struct ConfigGetSnapshot: Decodable, Sendable {
struct SnapshotConfig: Decodable, Sendable {
struct Session: Decodable, Sendable {
struct ConfigGetSnapshot: Decodable {
struct SnapshotConfig: Decodable {
struct Session: Decodable {
let mainKey: String?
let scope: String?
}
@@ -691,7 +729,7 @@ extension GatewayConnection {
// MARK: - Cron
struct CronSchedulerStatus: Decodable, Sendable {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let jobs: Int
@@ -703,17 +741,17 @@ extension GatewayConnection {
}
func cronList(includeDisabled: Bool = true) async throws -> [CronJob] {
let res: CronListResponse = try await self.requestDecoded(
let data = try await self.requestRaw(
method: .cronList,
params: ["includeDisabled": AnyCodable(includeDisabled)])
return res.jobs
return try Self.decodeCronListResponse(data)
}
func cronRuns(jobId: String, limit: Int = 200) async throws -> [CronRunLogEntry] {
let res: CronRunsResponse = try await self.requestDecoded(
let data = try await self.requestRaw(
method: .cronRuns,
params: ["id": AnyCodable(jobId), "limit": AnyCodable(limit)])
return res.entries
return try Self.decodeCronRunsResponse(data)
}
func cronRun(jobId: String, force: Bool = true) async throws {
@@ -739,4 +777,24 @@ extension GatewayConnection {
func cronAdd(payload: [String: AnyCodable]) async throws {
try await self.requestVoid(method: .cronAdd, params: payload)
}
nonisolated static func decodeCronListResponse(_ data: Data) throws -> [CronJob] {
let decoded = try JSONDecoder().decode(LossyCronListResponse.self, from: data)
let jobs = decoded.jobs.compactMap(\.value)
let skipped = decoded.jobs.count - jobs.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.list skipped \(skipped, privacy: .public) malformed jobs")
}
return jobs
}
nonisolated static func decodeCronRunsResponse(_ data: Data) throws -> [CronRunLogEntry] {
let decoded = try JSONDecoder().decode(LossyCronRunsResponse.self, from: data)
let entries = decoded.entries.compactMap(\.value)
let skipped = decoded.entries.count - entries.count
if skipped > 0 {
gatewayConnectionLogger.warning("cron.runs skipped \(skipped, privacy: .public) malformed entries")
}
return entries
}
}

View File

@@ -2,7 +2,7 @@ import ConcurrencyExtras
import Foundation
import OSLog
enum GatewayEndpointState: Sendable, Equatable {
enum GatewayEndpointState: Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
case connecting(mode: AppState.ConnectionMode, detail: String)
case unavailable(mode: AppState.ConnectionMode, reason: String)
@@ -24,14 +24,14 @@ actor GatewayEndpointStore {
]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
private static let staticLogger = Logger(subsystem: "ai.openclaw", category: "gateway-endpoint")
private enum EnvOverrideWarningKind: Sendable {
private enum EnvOverrideWarningKind {
case token
case password
}
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
struct Deps: Sendable {
struct Deps {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let password: @Sendable () -> String?
@@ -614,6 +614,44 @@ actor GatewayEndpointStore {
}
extension GatewayEndpointStore {
static func localConfig() -> GatewayConnection.Config {
self.localConfig(
root: OpenClawConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment,
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot(),
tailscaleIP: TailscaleService.fallbackTailnetIPv4())
}
static func localConfig(
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot?,
tailscaleIP: String?) -> GatewayConnection.Config
{
let port = GatewayEnvironment.gatewayPort()
let bind = self.resolveGatewayBindMode(root: root, env: env)
let customBindHost = self.resolveGatewayCustomBindHost(root: root)
let scheme = self.resolveGatewayScheme(root: root, env: env)
let host = self.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
let token = self.resolveGatewayToken(
isRemote: false,
root: root,
env: env,
launchdSnapshot: launchdSnapshot)
let password = self.resolveGatewayPassword(
isRemote: false,
root: root,
env: env,
launchdSnapshot: launchdSnapshot)
return (
url: URL(string: "\(scheme)://\(host):\(port)")!,
token: token,
password: password)
}
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "/" }
@@ -721,5 +759,18 @@ extension GatewayEndpointStore {
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
}
static func _testLocalConfig(
root: [String: Any],
env: [String: String],
launchdSnapshot: LaunchAgentPlistSnapshot? = nil,
tailscaleIP: String? = nil) -> GatewayConnection.Config
{
self.localConfig(
root: root,
env: env,
launchdSnapshot: launchdSnapshot,
tailscaleIP: tailscaleIP)
}
}
#endif

View File

@@ -3,7 +3,7 @@ import OpenClawIPC
import OSLog
/// Lightweight SemVer helper (major.minor.patch only) for gateway compatibility checks.
struct Semver: Comparable, CustomStringConvertible, Sendable {
struct Semver: Comparable, CustomStringConvertible {
let major: Int
let minor: Int
let patch: Int

View File

@@ -3,14 +3,14 @@ import Network
import Observation
import SwiftUI
struct HealthSnapshot: Codable, Sendable {
struct ChannelSummary: Codable, Sendable {
struct Probe: Codable, Sendable {
struct Bot: Codable, Sendable {
struct HealthSnapshot: Codable {
struct ChannelSummary: Codable {
struct Probe: Codable {
struct Bot: Codable {
let username: String?
}
struct Webhook: Codable, Sendable {
struct Webhook: Codable {
let url: String?
}
@@ -29,13 +29,13 @@ struct HealthSnapshot: Codable, Sendable {
let lastProbeAt: Double?
}
struct SessionInfo: Codable, Sendable {
struct SessionInfo: Codable {
let key: String
let updatedAt: Double?
let age: Double?
}
struct Sessions: Codable, Sendable {
struct Sessions: Codable {
let path: String
let count: Int
let recent: [SessionInfo]

View File

@@ -1,7 +1,7 @@
import Foundation
enum Launchctl {
struct Result: Sendable {
struct Result {
let status: Int32
let output: String
}
@@ -26,7 +26,7 @@ enum Launchctl {
}
}
struct LaunchAgentPlistSnapshot: Equatable, Sendable {
struct LaunchAgentPlistSnapshot: Equatable {
let programArguments: [String]
let environment: [String: String]
let stdoutPath: String?

View File

@@ -0,0 +1,234 @@
import Foundation
import OpenClawProtocol
import UniformTypeIdentifiers
actor MacNodeBrowserProxy {
static let shared = MacNodeBrowserProxy()
struct Endpoint {
let baseURL: URL
let token: String?
let password: String?
}
private struct RequestParams: Decodable {
let method: String?
let path: String?
let query: [String: OpenClawProtocol.AnyCodable]?
let body: OpenClawProtocol.AnyCodable?
let timeoutMs: Int?
let profile: String?
}
private struct ProxyFilePayload {
let path: String
let base64: String
let mimeType: String?
func asJSON() -> [String: Any] {
var json: [String: Any] = [
"path": self.path,
"base64": self.base64,
]
if let mimeType = self.mimeType {
json["mimeType"] = mimeType
}
return json
}
}
private static let maxProxyFileBytes = 10 * 1024 * 1024
private let endpointProvider: @Sendable () -> Endpoint
private let performRequest: @Sendable (URLRequest) async throws -> (Data, URLResponse)
init(
session: URLSession = .shared,
endpointProvider: (@Sendable () -> Endpoint)? = nil,
performRequest: (@Sendable (URLRequest) async throws -> (Data, URLResponse))? = nil)
{
self.endpointProvider = endpointProvider ?? MacNodeBrowserProxy.defaultEndpoint
self.performRequest = performRequest ?? { request in
try await session.data(for: request)
}
}
func request(paramsJSON: String?) async throws -> String {
let params = try Self.decodeRequestParams(from: paramsJSON)
let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider())
let (data, response) = try await self.performRequest(request)
let http = try Self.requireHTTPResponse(response)
guard (200..<300).contains(http.statusCode) else {
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
NSLocalizedDescriptionKey: Self.httpErrorMessage(statusCode: http.statusCode, data: data),
])
}
let result = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed])
let files = try Self.loadProxyFiles(from: result)
var payload: [String: Any] = ["result": result]
if !files.isEmpty {
payload["files"] = files.map { $0.asJSON() }
}
let payloadData = try JSONSerialization.data(withJSONObject: payload)
guard let payloadJSON = String(data: payloadData, encoding: .utf8) else {
throw NSError(domain: "MacNodeBrowserProxy", code: 2, userInfo: [
NSLocalizedDescriptionKey: "browser proxy returned invalid UTF-8",
])
}
return payloadJSON
}
private static func defaultEndpoint() -> Endpoint {
let config = GatewayEndpointStore.localConfig()
let controlPort = GatewayEnvironment.gatewayPort() + 2
let baseURL = URL(string: "http://127.0.0.1:\(controlPort)")!
return Endpoint(baseURL: baseURL, token: config.token, password: config.password)
}
private static func decodeRequestParams(from raw: String?) throws -> RequestParams {
guard let raw else {
throw NSError(domain: "MacNodeBrowserProxy", code: 3, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}
return try JSONDecoder().decode(RequestParams.self, from: Data(raw.utf8))
}
private static func makeRequest(params: RequestParams, endpoint: Endpoint) throws -> URLRequest {
let method = (params.method ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
let path = (params.path ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !path.isEmpty else {
throw NSError(domain: "MacNodeBrowserProxy", code: 1, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: path required",
])
}
let normalizedPath = path.hasPrefix("/") ? path : "/\(path)"
guard var components = URLComponents(
url: endpoint.baseURL.appendingPathComponent(String(normalizedPath.dropFirst())),
resolvingAgainstBaseURL: false)
else {
throw NSError(domain: "MacNodeBrowserProxy", code: 4, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
])
}
var queryItems: [URLQueryItem] = []
if let query = params.query {
for key in query.keys.sorted() {
let value = query[key]?.value
guard value != nil, !(value is NSNull) else { continue }
queryItems.append(URLQueryItem(name: key, value: Self.stringValue(for: value)))
}
}
let profile = params.profile?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !profile.isEmpty, !queryItems.contains(where: { $0.name == "profile" }) {
queryItems.append(URLQueryItem(name: "profile", value: profile))
}
if !queryItems.isEmpty {
components.queryItems = queryItems
}
guard let url = components.url else {
throw NSError(domain: "MacNodeBrowserProxy", code: 5, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: invalid browser proxy URL",
])
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = params.timeoutMs.map { TimeInterval(max($0, 1)) / 1000 } ?? 5
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token = endpoint.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else if let password = endpoint.password?.trimmingCharacters(in: .whitespacesAndNewlines),
!password.isEmpty
{
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
}
if method != "GET", let body = params.body?.value {
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private static func requireHTTPResponse(_ response: URLResponse) throws -> HTTPURLResponse {
guard let http = response as? HTTPURLResponse else {
throw NSError(domain: "MacNodeBrowserProxy", code: 6, userInfo: [
NSLocalizedDescriptionKey: "browser proxy returned a non-HTTP response",
])
}
return http
}
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
let error = object["error"] as? String,
!error.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return error
}
if let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
{
return text
}
return "HTTP \(statusCode)"
}
private static func stringValue(for value: Any?) -> String? {
guard let value else { return nil }
if let string = value as? String { return string }
if let bool = value as? Bool { return bool ? "true" : "false" }
if let number = value as? NSNumber { return number.stringValue }
return String(describing: value)
}
private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] {
let paths = self.collectProxyPaths(from: result)
return try paths.map(self.loadProxyFile)
}
private static func collectProxyPaths(from payload: Any) -> [String] {
guard let object = payload as? [String: Any] else { return [] }
var paths = Set<String>()
if let path = object["path"] as? String, !path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
}
if let imagePath = object["imagePath"] as? String,
!imagePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
paths.insert(imagePath.trimmingCharacters(in: .whitespacesAndNewlines))
}
if let download = object["download"] as? [String: Any],
let path = download["path"] as? String,
!path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
paths.insert(path.trimmingCharacters(in: .whitespacesAndNewlines))
}
return paths.sorted()
}
private static func loadProxyFile(path: String) throws -> ProxyFilePayload {
let url = URL(fileURLWithPath: path)
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else {
throw NSError(domain: "MacNodeBrowserProxy", code: 7, userInfo: [
NSLocalizedDescriptionKey: "browser proxy file not found: \(path)",
])
}
if let fileSize = values.fileSize, fileSize > Self.maxProxyFileBytes {
throw NSError(domain: "MacNodeBrowserProxy", code: 8, userInfo: [
NSLocalizedDescriptionKey: "browser proxy file exceeds 10MB: \(path)",
])
}
let data = try Data(contentsOf: url)
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
return ProxyFilePayload(path: path, base64: data.base64EncodedString(), mimeType: mimeType)
}
}

View File

@@ -32,6 +32,7 @@ final class MacNodeModeCoordinator {
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
var lastBrowserControlEnabled: Bool?
let defaults = UserDefaults.standard
while !Task.isCancelled {
@@ -48,6 +49,14 @@ final class MacNodeModeCoordinator {
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
let browserControlEnabled = OpenClawConfigFile.browserControlEnabled()
if lastBrowserControlEnabled == nil {
lastBrowserControlEnabled = browserControlEnabled
} else if lastBrowserControlEnabled != browserControlEnabled {
lastBrowserControlEnabled = browserControlEnabled
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
do {
let config = try await GatewayEndpointStore.shared.requireConfig()
@@ -108,6 +117,9 @@ final class MacNodeModeCoordinator {
private func currentCaps() -> [String] {
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
if OpenClawConfigFile.browserControlEnabled() {
caps.append(OpenClawCapability.browser.rawValue)
}
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
caps.append(OpenClawCapability.camera.rawValue)
}
@@ -142,6 +154,9 @@ final class MacNodeModeCoordinator {
]
let capsSet = Set(caps)
if capsSet.contains(OpenClawCapability.browser.rawValue) {
commands.append(OpenClawBrowserCommand.proxy.rawValue)
}
if capsSet.contains(OpenClawCapability.camera.rawValue) {
commands.append(OpenClawCameraCommand.list.rawValue)
commands.append(OpenClawCameraCommand.snap.rawValue)

View File

@@ -6,6 +6,7 @@ import OpenClawKit
actor MacNodeRuntime {
private let cameraCapture = CameraCaptureService()
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private let browserProxyRequest: @Sendable (String?) async throws -> String
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
private var eventSender: (@Sendable (String, String?) async -> Void)?
@@ -13,9 +14,13 @@ actor MacNodeRuntime {
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
await MainActor.run { LiveMacNodeRuntimeMainActorServices() }
},
browserProxyRequest: @escaping @Sendable (String?) async throws -> String = { paramsJSON in
try await MacNodeBrowserProxy.shared.request(paramsJSON: paramsJSON)
})
{
self.makeMainActorServices = makeMainActorServices
self.browserProxyRequest = browserProxyRequest
}
func updateMainSessionKey(_ sessionKey: String) {
@@ -50,6 +55,8 @@ actor MacNodeRuntime {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue:
return try await self.handleA2UIInvoke(req)
case OpenClawBrowserCommand.proxy.rawValue:
return try await self.handleBrowserProxyInvoke(req)
case OpenClawCameraCommand.snap.rawValue,
OpenClawCameraCommand.clip.rawValue,
OpenClawCameraCommand.list.rawValue:
@@ -165,6 +172,19 @@ actor MacNodeRuntime {
}
}
private func handleBrowserProxyInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard OpenClawConfigFile.browserControlEnabled() else {
return BridgeInvokeResponse(
id: req.id,
ok: false,
error: OpenClawNodeError(
code: .unavailable,
message: "BROWSER_DISABLED: enable Browser in Settings"))
}
let payloadJSON = try await self.browserProxyRequest(req.paramsJSON)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payloadJSON)
}
private func handleCameraInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
guard Self.cameraEnabled() else {
return BridgeInvokeResponse(

View File

@@ -1,10 +1,10 @@
import Foundation
enum MacNodeScreenCommand: String, Codable, Sendable {
enum MacNodeScreenCommand: String, Codable {
case record = "screen.record"
}
struct MacNodeScreenRecordParams: Codable, Sendable, Equatable {
struct MacNodeScreenRecordParams: Codable, Equatable {
var screenIndex: Int?
var durationMs: Int?
var fps: Double?

View File

@@ -61,9 +61,11 @@ final class NotifyOverlayController {
self.ensureWindow()
self.hostingView?.rootView = NotifyOverlayView(controller: self)
let target = self.targetFrame()
let isFirst = !self.model.isVisible
if isFirst { self.model.isVisible = true }
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
isFirstPresent: isFirst,
target: target)
{ window in
self.updateWindowFrame(animate: true)

View File

@@ -64,15 +64,14 @@ enum OverlayPanelFactory {
@MainActor
static func present(
window: NSWindow?,
isVisible: inout Bool,
isFirstPresent: Bool,
target: NSRect,
startOffsetY: CGFloat = -6,
onFirstPresent: (() -> Void)? = nil,
onAlreadyVisible: (NSWindow) -> Void)
{
guard let window else { return }
if !isVisible {
isVisible = true
if isFirstPresent {
onFirstPresent?()
let start = target.offsetBy(dx: 0, dy: startOffsetY)
self.animatePresent(window: window, from: start, to: target)
@@ -87,7 +86,7 @@ enum OverlayPanelFactory {
offsetX: CGFloat = 6,
offsetY: CGFloat = 6,
duration: TimeInterval = 0.16,
completion: @escaping () -> Void)
completion: @escaping @MainActor @Sendable () -> Void)
{
let target = window.frame.offsetBy(dx: offsetX, dy: offsetY)
NSAnimationContext.runAnimationGroup { context in
@@ -96,7 +95,7 @@ enum OverlayPanelFactory {
window.animator().setFrame(target, display: true)
window.animator().alphaValue = 0
} completionHandler: {
completion()
Task { @MainActor in completion() }
}
}
@@ -109,10 +108,8 @@ enum OverlayPanelFactory {
onHidden: @escaping @MainActor () -> Void)
{
self.animateDismiss(window: window, offsetX: offsetX, offsetY: offsetY, duration: duration) {
Task { @MainActor in
window.orderOut(nil)
onHidden()
}
window.orderOut(nil)
onHidden()
}
}

View File

@@ -56,7 +56,7 @@ final class PeekabooBridgeHostCoordinator {
private func startIfNeeded() async {
guard self.host == nil else { return }
var allowlistedTeamIDs: Set<String> = ["Y5PE65HELJ"]
var allowlistedTeamIDs: Set = ["Y5PE65HELJ"]
if let teamID = Self.currentTeamID() {
allowlistedTeamIDs.insert(teamID)
}

View File

@@ -9,24 +9,28 @@ struct PermissionsSettings: View {
let showOnboarding: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
SystemRunSettingsView()
Text("Allow these so OpenClaw can notify and capture when needed.")
.padding(.top, 4)
Text("Allow these so OpenClaw can notify and capture when needed.")
.padding(.top, 4)
.fixedSize(horizontal: false, vertical: true)
PermissionStatusList(status: self.status, refresh: self.refresh)
.padding(.horizontal, 2)
.padding(.vertical, 6)
PermissionStatusList(status: self.status, refresh: self.refresh)
.padding(.horizontal, 2)
.padding(.vertical, 6)
LocationAccessSettings()
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
Spacer()
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.vertical, 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@@ -99,11 +103,16 @@ private struct LocationAccessSettings: View {
struct PermissionStatusList: View {
let status: [Capability: Bool]
let refresh: () async -> Void
@State private var pendingCapability: Capability?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ForEach(Capability.allCases, id: \.self) { cap in
PermissionRow(capability: cap, status: self.status[cap] ?? false) {
PermissionRow(
capability: cap,
status: self.status[cap] ?? false,
isPending: self.pendingCapability == cap)
{
Task { await self.handle(cap) }
}
}
@@ -122,20 +131,43 @@ struct PermissionStatusList: View {
@MainActor
private func handle(_ cap: Capability) async {
guard self.pendingCapability == nil else { return }
self.pendingCapability = cap
defer { self.pendingCapability = nil }
_ = await PermissionManager.ensure([cap], interactive: true)
await self.refreshStatusTransitions()
}
@MainActor
private func refreshStatusTransitions() async {
await self.refresh()
// TCC and notification settings can settle after the prompt closes or when the app regains focus.
for delay in [300_000_000, 900_000_000, 1_800_000_000] {
try? await Task.sleep(nanoseconds: UInt64(delay))
await self.refresh()
}
}
}
struct PermissionRow: View {
let capability: Capability
let status: Bool
let isPending: Bool
let compact: Bool
let action: () -> Void
init(capability: Capability, status: Bool, compact: Bool = false, action: @escaping () -> Void) {
init(
capability: Capability,
status: Bool,
isPending: Bool = false,
compact: Bool = false,
action: @escaping () -> Void)
{
self.capability = capability
self.status = status
self.isPending = isPending
self.compact = compact
self.action = action
}
@@ -150,17 +182,49 @@ struct PermissionRow: View {
}
VStack(alignment: .leading, spacing: 2) {
Text(self.title).font(.body.weight(.semibold))
Text(self.subtitle).font(.caption).foregroundStyle(.secondary)
Text(self.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
if self.status {
Label("Granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
} else {
Button("Grant") { self.action() }
.buttonStyle(.bordered)
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
VStack(alignment: .trailing, spacing: 4) {
if self.status {
Label("Granted", systemImage: "checkmark.circle.fill")
.labelStyle(.iconOnly)
.foregroundStyle(.green)
.font(.title3)
.help("Granted")
} else if self.isPending {
ProgressView()
.controlSize(.small)
.frame(width: 78)
} else {
Button("Grant") { self.action() }
.buttonStyle(.bordered)
.controlSize(self.compact ? .small : .regular)
.frame(minWidth: self.compact ? 68 : 78, alignment: .trailing)
}
if self.status {
Text("Granted")
.font(.caption.weight(.medium))
.foregroundStyle(.green)
} else if self.isPending {
Text("Checking…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Request access")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(minWidth: self.compact ? 86 : 104, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
.padding(.vertical, self.compact ? 4 : 6)
}

View File

@@ -15,7 +15,7 @@ actor PortGuardian {
let timestamp: TimeInterval
}
struct Descriptor: Sendable {
struct Descriptor {
let pid: Int32
let command: String
let executablePath: String?

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.3.7</string>
<string>2026.3.8</string>
<key>CFBundleVersion</key>
<string>202603070</string>
<string>202603080</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -4,13 +4,13 @@ import OpenClawProtocol
import OSLog
import SwiftUI
struct SessionPreviewItem: Identifiable, Sendable {
struct SessionPreviewItem: Identifiable {
let id: String
let role: PreviewRole
let text: String
}
enum PreviewRole: String, Sendable {
enum PreviewRole: String {
case user
case assistant
case tool
@@ -114,7 +114,7 @@ extension SessionPreviewCache {
}
#endif
struct SessionMenuPreviewSnapshot: Sendable {
struct SessionMenuPreviewSnapshot {
let items: [SessionPreviewItem]
let status: SessionMenuPreviewView.LoadStatus
}

View File

@@ -1,3 +1,4 @@
import AppKit
import Observation
import SwiftUI
@@ -98,6 +99,10 @@ struct SettingsRootView: View {
.onChange(of: self.selectedTab) { _, newValue in
self.updatePermissionMonitoring(for: newValue)
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
guard self.selectedTab == .permissions else { return }
Task { await self.refreshPerms() }
}
.onDisappear { self.stopPermissionMonitoring() }
.task {
guard !self.isPreview else { return }

View File

@@ -152,7 +152,7 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate {
}
}
struct TalkPlaybackResult: Sendable {
struct TalkPlaybackResult {
let finished: Bool
let interruptedAt: Double?
}

View File

@@ -30,9 +30,11 @@ final class TalkOverlayController {
self.ensureWindow()
self.hostingView?.rootView = TalkOverlayView(controller: self)
let target = self.targetFrame()
let isFirst = !self.model.isVisible
if isFirst { self.model.isVisible = true }
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
isFirstPresent: isFirst,
target: target)
{ window in
window.setFrame(target, display: true)

View File

@@ -2,7 +2,7 @@ import AppKit
import Foundation
import OSLog
enum VoiceWakeChime: Codable, Equatable, Sendable {
enum VoiceWakeChime: Codable, Equatable {
case none
case system(name: String)
case custom(displayName: String, bookmark: Data)

View File

@@ -32,7 +32,7 @@ enum VoiceWakeForwarder {
}
}
struct ForwardOptions: Sendable {
struct ForwardOptions {
var sessionKey: String = "main"
var thinking: String = "low"
var deliver: Bool = true

View File

@@ -13,9 +13,11 @@ extension VoiceWakeOverlayController {
self.ensureWindow()
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
let target = self.targetFrame()
let isFirst = !self.model.isVisible
if isFirst { self.model.isVisible = true }
OverlayPanelFactory.present(
window: self.window,
isVisible: &self.model.isVisible,
isFirstPresent: isFirst,
target: target,
onFirstPresent: {
self.logger.log(

View File

@@ -16,7 +16,7 @@ private enum WebChatSwiftUILayout {
static let anchorPadding: CGFloat = 8
}
struct MacGatewayChatTransport: OpenClawChatTransport, Sendable {
struct MacGatewayChatTransport: OpenClawChatTransport {
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
}

View File

@@ -374,9 +374,9 @@ public final class GatewayDiscoveryModel {
if let host = gateway.serviceHost?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased(),
!host.isEmpty,
let port = gateway.servicePort,
port > 0
!host.isEmpty,
let port = gateway.servicePort,
port > 0
{
return "endpoint|\(host):\(port)"
}
@@ -674,7 +674,7 @@ public final class GatewayDiscoveryModel {
}
}
struct ResolvedGatewayService: Equatable, Sendable {
struct ResolvedGatewayService: Equatable {
var txt: [String: String]
var host: String?
var port: Int?

View File

@@ -1,7 +1,7 @@
import Foundation
import OpenClawKit
struct TailscaleServeGatewayBeacon: Sendable, Equatable {
struct TailscaleServeGatewayBeacon: Equatable {
var displayName: String
var tailnetDns: String
var host: String
@@ -13,7 +13,7 @@ enum TailscaleServeGatewayDiscovery {
private static let probeConcurrency = 6
private static let defaultProbeTimeoutSeconds: TimeInterval = 1.6
struct DiscoveryContext: Sendable {
struct DiscoveryContext {
var tailscaleStatus: @Sendable () async -> String?
var probeHost: @Sendable (_ host: String, _ timeout: TimeInterval) async -> Bool
@@ -85,13 +85,13 @@ enum TailscaleServeGatewayDiscovery {
}
}
private struct Candidate: Sendable {
private struct Candidate {
var dnsName: String
var displayName: String
}
private static func collectCandidates(status: TailscaleStatus) -> [Candidate] {
let selfDns = normalizeDnsName(status.selfNode?.dnsName)
let selfDns = self.normalizeDnsName(status.selfNode?.dnsName)
var out: [Candidate] = []
var seen = Set<String>()
@@ -112,7 +112,7 @@ enum TailscaleServeGatewayDiscovery {
out.append(Candidate(
dnsName: dnsName,
displayName: displayName(hostName: node.hostName, dnsName: dnsName)))
displayName: self.displayName(hostName: node.hostName, dnsName: dnsName)))
if out.count >= self.maxCandidates {
break
@@ -257,7 +257,7 @@ enum TailscaleServeGatewayDiscovery {
operation: {
while true {
let message = try await task.receive()
if isConnectChallenge(message: message) {
if self.isConnectChallenge(message: message) {
return true
}
}

View File

@@ -1,7 +1,7 @@
import Foundation
import OpenClawKit
struct WideAreaGatewayBeacon: Sendable, Equatable {
struct WideAreaGatewayBeacon: Equatable {
var instanceName: String
var displayName: String
var host: String
@@ -19,7 +19,7 @@ enum WideAreaGatewayDiscovery {
private static let defaultTimeoutSeconds: TimeInterval = 0.2
private static let nameserverProbeConcurrency = 6
struct DiscoveryContext: Sendable {
struct DiscoveryContext {
var tailscaleStatus: @Sendable () -> String?
var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?

View File

@@ -3,11 +3,10 @@ import OpenClawProtocol
import Testing
@testable import OpenClaw
@Suite
@MainActor
struct AgentEventStoreTests {
@Test
func appendAndClear() {
func `append and clear`() {
let store = AgentEventStore()
#expect(store.events.isEmpty)
@@ -25,7 +24,7 @@ struct AgentEventStoreTests {
}
@Test
func trimsToMaxEvents() {
func `trims to max events`() {
let store = AgentEventStore()
for i in 1...401 {
store.append(ControlAgentEvent(

View File

@@ -2,10 +2,9 @@ import Foundation
import Testing
@testable import OpenClaw
@Suite
struct AgentWorkspaceTests {
@Test
func displayPathUsesTildeForHome() {
func `display path uses tilde for home`() {
let home = FileManager().homeDirectoryForCurrentUser
#expect(AgentWorkspace.displayPath(for: home) == "~")
@@ -14,20 +13,20 @@ struct AgentWorkspaceTests {
}
@Test
func resolveWorkspaceURLExpandsTilde() {
func `resolve workspace URL expands tilde`() {
let url = AgentWorkspace.resolveWorkspaceURL(from: "~/tmp")
#expect(url.path.hasSuffix("/tmp"))
}
@Test
func agentsURLAppendsFilename() {
func `agents URL appends filename`() {
let root = URL(fileURLWithPath: "/tmp/ws", isDirectory: true)
let url = AgentWorkspace.agentsURL(workspaceURL: root)
#expect(url.lastPathComponent == AgentWorkspace.agentsFilename)
}
@Test
func bootstrapCreatesAgentsFileWhenMissing() throws {
func `bootstrap creates agents file when missing`() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
@@ -50,7 +49,7 @@ struct AgentWorkspaceTests {
}
@Test
func bootstrapSafetyRejectsNonEmptyFolderWithoutAgents() throws {
func `bootstrap safety rejects non empty folder without agents`() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
@@ -63,7 +62,7 @@ struct AgentWorkspaceTests {
}
@Test
func bootstrapSafetyAllowsExistingAgentsFile() throws {
func `bootstrap safety allows existing agents file`() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
@@ -76,7 +75,7 @@ struct AgentWorkspaceTests {
}
@Test
func bootstrapSkipsBootstrapFileWhenWorkspaceHasContent() throws {
func `bootstrap skips bootstrap file when workspace has content`() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }
@@ -91,7 +90,7 @@ struct AgentWorkspaceTests {
}
@Test
func needsBootstrapFalseWhenIdentityAlreadySet() throws {
func `needs bootstrap false when identity already set`() throws {
let tmp = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-ws-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: tmp) }

View File

@@ -3,8 +3,8 @@ import OpenClawProtocol
import Testing
@testable import OpenClaw
@Suite struct AnyCodableEncodingTests {
@Test func encodesSwiftArrayAndDictionaryValues() throws {
struct AnyCodableEncodingTests {
@Test func `encodes swift array and dictionary values`() throws {
let payload: [String: Any] = [
"tags": ["node", "ios"],
"meta": ["count": 2],
@@ -19,7 +19,7 @@ import Testing
#expect(obj["null"] is NSNull)
}
@Test func protocolAnyCodableEncodesPrimitiveArrays() throws {
@Test func `protocol any codable encodes primitive arrays`() throws {
let payload: [String: Any] = [
"items": [1, "two", NSNull(), ["ok": true]],
]

View File

@@ -2,15 +2,15 @@ import Foundation
import Testing
@testable import OpenClaw
@Suite struct AudioInputDeviceObserverTests {
@Test func hasUsableDefaultInputDeviceReturnsBool() {
struct AudioInputDeviceObserverTests {
@Test func `has usable default input device returns bool`() {
// Smoke test: verifies the composition logic runs without crashing.
// Actual result depends on whether the host has an audio input device.
let result = AudioInputDeviceObserver.hasUsableDefaultInputDevice()
_ = result // suppress unused-variable warning; the assertion is "no crash"
}
@Test func hasUsableDefaultInputDeviceConsistentWithComponents() {
@Test func `has usable default input device consistent with components`() {
// When no default UID exists, the method must return false.
// When a default UID exists, the result must match alive-set membership.
let uid = AudioInputDeviceObserver.defaultInputDeviceUID()

View File

@@ -5,7 +5,7 @@ import Testing
@Suite(.serialized)
@MainActor
struct CLIInstallerTests {
@Test func installedLocationFindsExecutable() throws {
@Test func `installed location finds executable`() throws {
let fm = FileManager()
let root = fm.temporaryDirectory.appendingPathComponent(
"openclaw-cli-installer-\(UUID().uuidString)")

View File

@@ -1,14 +1,14 @@
import Testing
@testable import OpenClaw
@Suite struct CameraCaptureServiceTests {
@Test func normalizeSnapDefaults() {
struct CameraCaptureServiceTests {
@Test func `normalize snap defaults`() {
let res = CameraCaptureService.normalizeSnap(maxWidth: nil, quality: nil)
#expect(res.maxWidth == 1600)
#expect(res.quality == 0.9)
}
@Test func normalizeSnapClampsValues() {
@Test func `normalize snap clamps values`() {
let low = CameraCaptureService.normalizeSnap(maxWidth: -1, quality: -10)
#expect(low.maxWidth == 1600)
#expect(low.quality == 0.05)

View File

@@ -2,8 +2,8 @@ import Foundation
import OpenClawIPC
import Testing
@Suite struct CameraIPCTests {
@Test func cameraSnapCodableRoundtrip() throws {
struct CameraIPCTests {
@Test func `camera snap codable roundtrip`() throws {
let req: Request = .cameraSnap(
facing: .front,
maxWidth: 640,
@@ -24,7 +24,7 @@ import Testing
}
}
@Test func cameraClipCodableRoundtrip() throws {
@Test func `camera clip codable roundtrip`() throws {
let req: Request = .cameraClip(
facing: .back,
durationMs: 3000,
@@ -45,7 +45,7 @@ import Testing
}
}
@Test func cameraClipDefaultsIncludeAudioToTrueWhenMissing() throws {
@Test func `camera clip defaults include audio to true when missing`() throws {
let json = """
{"type":"cameraClip","durationMs":1234}
"""

View File

@@ -11,7 +11,7 @@ import Testing
return dir
}
@Test func detectsInPlaceFileWrites() async throws {
@Test func `detects in place file writes`() async throws {
let dir = try self.makeTempDir()
defer { try? FileManager().removeItem(at: dir) }

View File

@@ -2,8 +2,8 @@ import Foundation
import OpenClawIPC
import Testing
@Suite struct CanvasIPCTests {
@Test func canvasPresentCodableRoundtrip() throws {
struct CanvasIPCTests {
@Test func `canvas present codable roundtrip`() throws {
let placement = CanvasPlacement(x: 10, y: 20, width: 640, height: 480)
let req: Request = .canvasPresent(session: "main", path: "/index.html", placement: placement)
@@ -23,7 +23,7 @@ import Testing
}
}
@Test func canvasPresentDecodesNilPlacementWhenMissing() throws {
@Test func `canvas present decodes nil placement when missing`() throws {
let json = """
{"type":"canvasPresent","session":"s","path":"/"}
"""

View File

@@ -7,7 +7,7 @@ import Testing
@Suite(.serialized)
@MainActor
struct CanvasWindowSmokeTests {
@Test func panelControllerShowsAndHides() async throws {
@Test func `panel controller shows and hides`() async throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)
@@ -30,7 +30,7 @@ struct CanvasWindowSmokeTests {
controller.close()
}
@Test func windowControllerShowsAndCloses() throws {
@Test func `window controller shows and closes`() throws {
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-canvas-test-\(UUID().uuidString)")
try FileManager().createDirectory(at: root, withIntermediateDirectories: true)

View File

@@ -41,7 +41,7 @@ private func makeChannelsStore(
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@Test func channelsSettingsBuildsBodyWithSnapshot() {
@Test func `channels settings builds body with snapshot`() {
let store = makeChannelsStore(
channels: [
"whatsapp": SnapshotAnyCodable([
@@ -108,7 +108,7 @@ struct ChannelsSettingsSmokeTests {
_ = view.body
}
@Test func channelsSettingsBuildsBodyWithoutSnapshot() {
@Test func `channels settings builds body without snapshot`() {
let store = makeChannelsStore(
channels: [
"whatsapp": SnapshotAnyCodable([

View File

@@ -23,7 +23,7 @@ import Testing
return (tmp, pnpmPath)
}
@Test func prefersOpenClawBinary() throws {
@Test func `prefers open claw binary`() throws {
let defaults = self.makeLocalDefaults()
let tmp = try makeTempDirForTests()
@@ -36,7 +36,7 @@ import Testing
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
}
@Test func fallsBackToNodeAndScript() throws {
@Test func `falls back to node and script`() throws {
let defaults = self.makeLocalDefaults()
let tmp = try makeTempDirForTests()
@@ -63,7 +63,7 @@ import Testing
}
}
@Test func prefersOpenClawBinaryOverPnpm() throws {
@Test func `prefers open claw binary over pnpm`() throws {
let defaults = self.makeLocalDefaults()
let tmp = try makeTempDirForTests()
@@ -84,7 +84,7 @@ import Testing
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "rpc"]))
}
@Test func usesOpenClawBinaryWithoutNodeRuntime() throws {
@Test func `uses open claw binary without node runtime`() throws {
let defaults = self.makeLocalDefaults()
let tmp = try makeTempDirForTests()
@@ -103,7 +103,7 @@ import Testing
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
}
@Test func fallsBackToPnpm() throws {
@Test func `falls back to pnpm`() throws {
let defaults = self.makeLocalDefaults()
let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
@@ -116,7 +116,7 @@ import Testing
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "openclaw", "rpc"]))
}
@Test func pnpmKeepsExtraArgsAfterSubcommand() throws {
@Test func `pnpm keeps extra args after subcommand`() throws {
let defaults = self.makeLocalDefaults()
let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
@@ -131,7 +131,7 @@ import Testing
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
}
@Test func preferredPathsStartWithProjectNodeBins() throws {
@Test func `preferred paths start with project node bins`() throws {
let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
@@ -139,7 +139,7 @@ import Testing
#expect(first == tmp.appendingPathComponent("node_modules/.bin").path)
}
@Test func buildsSSHCommandForRemoteMode() {
@Test func `builds SSH command for remote mode`() {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
@@ -170,13 +170,13 @@ import Testing
}
}
@Test func rejectsUnsafeSSHTargets() {
@Test func `rejects unsafe SSH targets`() {
#expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
#expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
}
@Test func configRootLocalOverridesRemoteDefaults() throws {
@Test func `config root local overrides remote defaults`() throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)

View File

@@ -4,7 +4,7 @@ import Testing
@Suite(.serialized)
@MainActor
struct ConfigStoreTests {
@Test func loadUsesRemoteInRemoteMode() async {
@Test func `load uses remote in remote mode`() async {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
@@ -20,7 +20,7 @@ struct ConfigStoreTests {
#expect(result["remote"] as? Bool == true)
}
@Test func loadUsesLocalInLocalMode() async {
@Test func `load uses local in local mode`() async {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
@@ -36,7 +36,7 @@ struct ConfigStoreTests {
#expect(result["local"] as? Bool == true)
}
@Test func saveRoutesToRemoteInRemoteMode() async throws {
@Test func `save routes to remote in remote mode`() async throws {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(
@@ -51,7 +51,7 @@ struct ConfigStoreTests {
#expect(!localHit)
}
@Test func saveRoutesToLocalInLocalMode() async throws {
@Test func `save routes to local in local mode`() async throws {
var localHit = false
var remoteHit = false
await ConfigStore._testSetOverrides(.init(

View File

@@ -4,7 +4,7 @@ import Testing
@Suite(.serialized)
struct CoverageDumpTests {
@Test func periodicallyFlushCoverage() async {
@Test func `periodically flush coverage`() async {
guard ProcessInfo.processInfo.environment["LLVM_PROFILE_FILE"] != nil else { return }
guard let writeProfile = resolveProfileWriteFile() else { return }
let deadline = Date().addingTimeInterval(4)

View File

@@ -2,10 +2,9 @@ import AppKit
import Testing
@testable import OpenClaw
@Suite
@MainActor
struct CritterIconRendererTests {
@Test func makeIconRendersExpectedSize() {
@Test func `make icon renders expected size`() {
let image = CritterIconRenderer.makeIcon(
blink: 0.25,
legWiggle: 0.5,
@@ -19,7 +18,7 @@ struct CritterIconRendererTests {
#expect(image.tiffRepresentation != nil)
}
@Test func makeIconRendersWithBadge() {
@Test func `make icon renders with badge`() {
let image = CritterIconRenderer.makeIcon(
blink: 0,
legWiggle: 0,
@@ -31,7 +30,7 @@ struct CritterIconRendererTests {
#expect(image.tiffRepresentation != nil)
}
@Test func critterStatusLabelExercisesHelpers() async {
@Test func `critter status label exercises helpers`() async {
await CritterStatusLabel.exerciseForTesting()
}
}

View File

@@ -15,17 +15,17 @@ struct CronJobEditorSmokeTests {
onSave: { _ in })
}
@Test func statusPillBuildsBody() {
@Test func `status pill builds body`() {
_ = StatusPill(text: "ok", tint: .green).body
_ = StatusPill(text: "disabled", tint: .secondary).body
}
@Test func cronJobEditorBuildsBodyForNewJob() {
@Test func `cron job editor builds body for new job`() {
let view = self.makeEditor()
_ = view.body
}
@Test func cronJobEditorBuildsBodyForExistingJob() {
@Test func `cron job editor builds body for existing job`() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob(
id: "job-1",
@@ -60,12 +60,12 @@ struct CronJobEditorSmokeTests {
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
@Test func `cron job editor exercises builders`() {
var view = self.makeEditor()
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() {
@Test func `cron job editor includes delete after run for at schedule`() {
let view = self.makeEditor()
var root: [String: Any] = [:]

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