Compare commits
45 Commits
v2026.3.7-
...
fix/codex-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a91af22a5 | ||
|
|
e4bfcff5a8 | ||
|
|
c42dc2e8c2 | ||
|
|
e9d51d874b | ||
|
|
ec75643a09 | ||
|
|
374001c4a0 | ||
|
|
58ae5582f4 | ||
|
|
eebee84093 | ||
|
|
386b811ddd | ||
|
|
f66cc886d3 | ||
|
|
f930fcbd3f | ||
|
|
03aea082d0 | ||
|
|
5f45e76d61 | ||
|
|
53fb317e7f | ||
|
|
eb0758e172 | ||
|
|
04b4b48077 | ||
|
|
709e11ea70 | ||
|
|
46145fde19 | ||
|
|
1230cefe25 | ||
|
|
0f9566b0b5 | ||
|
|
492fe679a7 | ||
|
|
f4c4856254 | ||
|
|
8a20f51460 | ||
|
|
aedf3ee68f | ||
|
|
b38f371630 | ||
|
|
e5fdfec9dc | ||
|
|
f73778e9b2 | ||
|
|
c1b914026d | ||
|
|
9425209602 | ||
|
|
4db634964b | ||
|
|
6477da623f | ||
|
|
d3c3d0e730 | ||
|
|
92648f9ba9 | ||
|
|
d15b6af77b | ||
|
|
05217845a7 | ||
|
|
389647157d | ||
|
|
c217237a36 | ||
|
|
42a1394c5c | ||
|
|
c3810346f9 | ||
|
|
e0f80cf0e9 | ||
|
|
5d22bd0297 | ||
|
|
59102a1ff7 | ||
|
|
06ffef8465 | ||
|
|
c6a8ab69c6 | ||
|
|
fcdc1a13e1 |
@@ -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"
|
||||
}
|
||||
|
||||
22
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 23 KiB |
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0A0A0A</color>
|
||||
<color name="ic_launcher_background">#DD1A08</color>
|
||||
</resources>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct DevicePresentation: Sendable {
|
||||
struct DevicePresentation {
|
||||
let title: String
|
||||
let symbol: String?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct ExecCommandResolution: Sendable {
|
||||
struct ExecCommandResolution {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
let executableName: String
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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?
|
||||
|
||||
234
apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ actor PortGuardian {
|
||||
let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
struct Descriptor: Sendable {
|
||||
struct Descriptor {
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let executablePath: String?
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -152,7 +152,7 @@ final class TalkAudioPlayer: NSObject, @preconcurrency AVAudioPlayerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkPlaybackResult: Sendable {
|
||||
struct TalkPlaybackResult {
|
||||
let finished: Bool
|
||||
let interruptedAt: Double?
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,7 +32,7 @@ enum VoiceWakeForwarder {
|
||||
}
|
||||
}
|
||||
|
||||
struct ForwardOptions: Sendable {
|
||||
struct ForwardOptions {
|
||||
var sessionKey: String = "main"
|
||||
var thinking: String = "low"
|
||||
var deliver: Bool = true
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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]],
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
"""
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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":"/"}
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] = [:]
|
||||
|
||||