mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 07:51:14 +08:00
Compare commits
3 Commits
fix/sandbo
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61daccbd0f | ||
|
|
e6cf0bce5e | ||
|
|
0b8142f706 |
@@ -6,7 +6,15 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
interface FileInfo {
|
||||
status: string;
|
||||
@@ -100,17 +108,87 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
const items = files.map((file) => ({
|
||||
value: file,
|
||||
label: `${file.status} ${file.file}`,
|
||||
}));
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to diff",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
|
||||
|
||||
// Build select items with colored status
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
let statusColor: string;
|
||||
switch (f.status) {
|
||||
case "M":
|
||||
statusColor = theme.fg("warning", f.status);
|
||||
break;
|
||||
case "A":
|
||||
statusColor = theme.fg("success", f.status);
|
||||
break;
|
||||
case "D":
|
||||
statusColor = theme.fg("error", f.status);
|
||||
break;
|
||||
case "?":
|
||||
statusColor = theme.fg("muted", f.status);
|
||||
break;
|
||||
default:
|
||||
statusColor = theme.fg("dim", f.status);
|
||||
}
|
||||
return {
|
||||
value: f,
|
||||
label: `${statusColor} ${f.file}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
void openSelected(item.value as FileInfo);
|
||||
},
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { showPagedSelectList } from "./ui/paged-select";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
@@ -105,29 +113,81 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
};
|
||||
|
||||
const items = files.map((file) => {
|
||||
const ops: string[] = [];
|
||||
if (file.operations.has("read")) {
|
||||
ops.push("R");
|
||||
}
|
||||
if (file.operations.has("write")) {
|
||||
ops.push("W");
|
||||
}
|
||||
if (file.operations.has("edit")) {
|
||||
ops.push("E");
|
||||
}
|
||||
return {
|
||||
value: file,
|
||||
label: `${ops.join("")} ${file.path}`,
|
||||
};
|
||||
});
|
||||
await showPagedSelectList({
|
||||
ctx,
|
||||
title: " Select file to open",
|
||||
items,
|
||||
onSelect: (item) => {
|
||||
// Show file picker with SelectList
|
||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
|
||||
|
||||
// Build select items with colored operations
|
||||
const items: SelectItem[] = files.map((f) => {
|
||||
const ops: string[] = [];
|
||||
if (f.operations.has("read")) {
|
||||
ops.push(theme.fg("muted", "R"));
|
||||
}
|
||||
if (f.operations.has("write")) {
|
||||
ops.push(theme.fg("success", "W"));
|
||||
}
|
||||
if (f.operations.has("edit")) {
|
||||
ops.push(theme.fg("warning", "E"));
|
||||
}
|
||||
const opsLabel = ops.join("");
|
||||
return {
|
||||
value: f,
|
||||
label: `${opsLabel} ${f.path}`,
|
||||
};
|
||||
});
|
||||
|
||||
const visibleRows = Math.min(files.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(items, visibleRows, {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => t, // Keep existing colors
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
});
|
||||
selectList.onSelect = (item) => {
|
||||
void openSelected(item.value as FileEntry);
|
||||
},
|
||||
};
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
// Help text
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (w) => container.render(w),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
// Add paging with left/right
|
||||
if (matchesKey(data, Key.left)) {
|
||||
// Page up - clamp to 0
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
// Page down - clamp to last
|
||||
currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Container,
|
||||
Key,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
Text,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
type CustomUiContext = {
|
||||
ui: {
|
||||
custom: <T>(
|
||||
render: (
|
||||
tui: { requestRender: () => void },
|
||||
theme: {
|
||||
fg: (tone: string, text: string) => string;
|
||||
bold: (text: string) => string;
|
||||
},
|
||||
kb: unknown,
|
||||
done: () => void,
|
||||
) => {
|
||||
render: (width: number) => string;
|
||||
invalidate: () => void;
|
||||
handleInput: (data: string) => void;
|
||||
},
|
||||
) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export async function showPagedSelectList(params: {
|
||||
ctx: CustomUiContext;
|
||||
title: string;
|
||||
items: SelectItem[];
|
||||
onSelect: (item: SelectItem) => void;
|
||||
}): Promise<void> {
|
||||
await params.ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
||||
const container = new Container();
|
||||
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
|
||||
|
||||
const visibleRows = Math.min(params.items.length, 15);
|
||||
let currentIndex = 0;
|
||||
|
||||
const selectList = new SelectList(params.items, visibleRows, {
|
||||
selectedPrefix: (text) => theme.fg("accent", text),
|
||||
selectedText: (text) => text,
|
||||
description: (text) => theme.fg("muted", text),
|
||||
scrollInfo: (text) => theme.fg("dim", text),
|
||||
noMatch: (text) => theme.fg("warning", text),
|
||||
});
|
||||
selectList.onSelect = (item) => params.onSelect(item);
|
||||
selectList.onCancel = () => done();
|
||||
selectList.onSelectionChange = (item) => {
|
||||
currentIndex = params.items.indexOf(item);
|
||||
};
|
||||
container.addChild(selectList);
|
||||
|
||||
container.addChild(
|
||||
new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
|
||||
);
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
return {
|
||||
render: (width) => container.render(width),
|
||||
invalidate: () => container.invalidate(),
|
||||
handleInput: (data) => {
|
||||
if (matchesKey(data, Key.left)) {
|
||||
currentIndex = Math.max(0, currentIndex - visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else if (matchesKey(data, Key.right)) {
|
||||
currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
|
||||
selectList.setSelectedIndex(currentIndex);
|
||||
} else {
|
||||
selectList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -6,8 +6,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
|
||||
@@ -17,7 +15,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
|
||||
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
|
||||
- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
|
||||
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
|
||||
@@ -29,7 +26,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Web UI/i18n: add German (`de`) locale support and auto-render language options from supported locale constants in Overview settings. (#28495) thanks @dsantoreis.
|
||||
- Tools/Diffs: add a new optional `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs for canvas and PNG image output. Thanks @gumadeiras.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Shell env markers: set `OPENCLAW_SHELL` across shell-like runtimes (`exec`, `acp`, `acp-client`, `tui-local`) so shell startup/config rules can target OpenClaw contexts consistently, and document the markers in env/exec/acp/TUI docs. Thanks @vincentkoc.
|
||||
@@ -39,40 +35,15 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
|
||||
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
|
||||
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
|
||||
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
@@ -81,21 +52,15 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
|
||||
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
|
||||
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
|
||||
@@ -104,9 +69,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
|
||||
@@ -227,7 +190,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
|
||||
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Failure delivery routing: add `failureAlert.mode` (`announce|webhook`) and `failureAlert.accountId` support, plus `cron.failureDestination` and per-job `delivery.failureDestination` routing with duplicate-target suppression, best-effort skip behavior, and global+job merge semantics. Landed from contributor PR #31059 by @kesor. Thanks @kesor.
|
||||
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
|
||||
- Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena.
|
||||
@@ -241,7 +203,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman.
|
||||
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra.
|
||||
- Cron/Isolated payload selection: ignore `isError` payloads when deriving summary/output/delivery payload fallbacks, while preserving error-only fallback behavior when no non-error payload exists. (#21454) Thanks @Diaspar4u.
|
||||
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.
|
||||
- Cron/Timer hot-loop guard: enforce a minimum timer re-arm delay when stale past-due jobs would otherwise trigger repeated `setTimeout(0)` loops, preventing event-loop saturation and log-flood behavior. (#29853) Thanks @FlamesCN.
|
||||
- Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn.
|
||||
@@ -254,7 +215,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
|
||||
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
|
||||
- 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.
|
||||
- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
|
||||
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
|
||||
- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
|
||||
@@ -299,7 +259,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.
|
||||
- Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf.
|
||||
- Cron tool/update flat params: recover top-level update patch fields when models omit the `patch` wrapper, and allow flattened update keys through tool input schema validation so `cron.update` no longer fails with `patch required` for valid flat payloads. (#23221)
|
||||
- Cron/Announce delivery status: keep isolated cron runs in `ok` state when execution succeeds but announce delivery fails (for example transient `pairing required`), while preserving `delivered=false` and delivery error context for visibility. (#31082) Thanks @YuzuruS.
|
||||
- Agents/Message tool scoping: include other configured channels in scoped `message` tool action enum + description so isolated/cron runs can discover and invoke cross-channel actions without schema validation failures. Landed from contributor PR #20840 by @altaywtf. Thanks @altaywtf.
|
||||
- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington.
|
||||
- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11.
|
||||
|
||||
@@ -33,7 +33,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
@@ -98,7 +101,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(params) ?: 1600
|
||||
@@ -164,7 +167,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val params = parseParamsObject(paramsJson)
|
||||
val facing = parseFacing(params) ?: "front"
|
||||
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
|
||||
val includeAudio = parseIncludeAudio(params) ?: true
|
||||
@@ -290,8 +293,20 @@ class CameraCaptureManager(private val context: Context) {
|
||||
return rotated
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseFacing(params: JsonObject?): String? {
|
||||
val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
|
||||
val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"front", "back" -> value
|
||||
else -> null
|
||||
@@ -299,21 +314,31 @@ class CameraCaptureManager(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun parseQuality(params: JsonObject?): Double? =
|
||||
parseJsonDouble(params, "quality")
|
||||
readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseMaxWidth(params: JsonObject?): Int? =
|
||||
parseJsonInt(params, "maxWidth")
|
||||
readPrimitive(params, "maxWidth")
|
||||
?.contentOrNull
|
||||
?.toIntOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
parseJsonInt(params, "durationMs")
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseDeviceId(params: JsonObject?): String? =
|
||||
parseJsonString(params, "deviceId")
|
||||
readPrimitive(params, "deviceId")
|
||||
?.contentOrNull
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
|
||||
|
||||
|
||||
@@ -44,14 +44,6 @@ class CanvasController {
|
||||
return (q * 100.0).toInt().coerceIn(1, 100)
|
||||
}
|
||||
|
||||
private fun Bitmap.scaleForMaxWidth(maxWidth: Int?): Bitmap {
|
||||
if (maxWidth == null || maxWidth <= 0 || width <= maxWidth) {
|
||||
return this
|
||||
}
|
||||
val scaledHeight = (height.toDouble() * (maxWidth.toDouble() / width.toDouble())).toInt().coerceAtLeast(1)
|
||||
return scale(maxWidth, scaledHeight)
|
||||
}
|
||||
|
||||
fun attach(webView: WebView) {
|
||||
this.webView = webView
|
||||
reload()
|
||||
@@ -156,7 +148,13 @@ class CanvasController {
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
@@ -167,7 +165,13 @@ class CanvasController {
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
val scaled =
|
||||
if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
|
||||
val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
|
||||
bmp.scale(maxWidth, h)
|
||||
} else {
|
||||
bmp
|
||||
}
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
|
||||
@@ -248,37 +248,30 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
}
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||
contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
|
||||
contactId = contactId,
|
||||
)
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||
resolver.query(
|
||||
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||
projection,
|
||||
"${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
if (cursor == null) return emptyList()
|
||||
val out = LinkedHashSet<String>()
|
||||
while (cursor.moveToNext()) {
|
||||
val value = cursor.getString(0)?.trim().orEmpty()
|
||||
if (value.isNotEmpty()) out += value
|
||||
}
|
||||
return out.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEmails(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
|
||||
contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
|
||||
contactId = contactId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun queryContactValues(
|
||||
resolver: ContentResolver,
|
||||
contentUri: android.net.Uri,
|
||||
valueColumn: String,
|
||||
contactIdColumn: String,
|
||||
contactId: Long,
|
||||
): List<String> {
|
||||
val projection = arrayOf(valueColumn)
|
||||
val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS)
|
||||
resolver.query(
|
||||
contentUri,
|
||||
ContactsContract.CommonDataKinds.Email.CONTENT_URI,
|
||||
projection,
|
||||
"$contactIdColumn=?",
|
||||
"${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?",
|
||||
arrayOf(contactId.toString()),
|
||||
null,
|
||||
).use { cursor ->
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
@@ -34,21 +33,6 @@ data class DeviceNotificationEntry(
|
||||
val isClearable: Boolean,
|
||||
)
|
||||
|
||||
internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
|
||||
return buildJsonObject {
|
||||
put("key", JsonPrimitive(key))
|
||||
put("packageName", JsonPrimitive(packageName))
|
||||
put("postTimeMs", JsonPrimitive(postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(isOngoing))
|
||||
put("isClearable", JsonPrimitive(isClearable))
|
||||
title?.let { put("title", JsonPrimitive(it)) }
|
||||
text?.let { put("text", JsonPrimitive(it)) }
|
||||
subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
category?.let { put("category", JsonPrimitive(it)) }
|
||||
channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceNotificationSnapshot(
|
||||
val enabled: Boolean,
|
||||
val connected: Boolean,
|
||||
|
||||
@@ -10,6 +10,7 @@ import ai.openclaw.android.protocol.OpenClawDeviceCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawMotionCommand
|
||||
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawPhotosCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSystemCommand
|
||||
@@ -145,9 +146,7 @@ class InvokeDispatcher(
|
||||
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
|
||||
|
||||
// Photos command
|
||||
ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
|
||||
paramsJson,
|
||||
)
|
||||
OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
|
||||
|
||||
// Contacts command
|
||||
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
@@ -23,35 +21,6 @@ fun String.toJsonString(): String {
|
||||
|
||||
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
|
||||
|
||||
fun parseJsonInt(params: JsonObject?, key: String): Int? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
|
||||
|
||||
fun parseJsonDouble(params: JsonObject?, key: String): Double? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
fun parseJsonString(params: JsonObject?, key: String): String? =
|
||||
readJsonPrimitive(params, key)?.contentOrNull
|
||||
|
||||
fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
|
||||
val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
|
||||
@@ -131,7 +131,20 @@ class NotificationsHandler private constructor(
|
||||
put(
|
||||
"notifications",
|
||||
JsonArray(
|
||||
snapshot.notifications.map { entry -> entry.toJsonObject() },
|
||||
snapshot.notifications.map { entry ->
|
||||
buildJsonObject {
|
||||
put("key", JsonPrimitive(entry.key))
|
||||
put("packageName", JsonPrimitive(entry.packageName))
|
||||
put("postTimeMs", JsonPrimitive(entry.postTimeMs))
|
||||
put("isOngoing", JsonPrimitive(entry.isOngoing))
|
||||
put("isClearable", JsonPrimitive(entry.isClearable))
|
||||
entry.title?.let { put("title", JsonPrimitive(it)) }
|
||||
entry.text?.let { put("text", JsonPrimitive(it)) }
|
||||
entry.subText?.let { put("subText", JsonPrimitive(it)) }
|
||||
entry.category?.let { put("category", JsonPrimitive(it)) }
|
||||
entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}.toString()
|
||||
|
||||
@@ -10,7 +10,10 @@ import ai.openclaw.android.ScreenCaptureRequester
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -36,7 +39,7 @@ class ScreenRecordManager(private val context: Context) {
|
||||
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
|
||||
)
|
||||
|
||||
val params = parseJsonParamsObject(paramsJson)
|
||||
val params = parseParamsObject(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)
|
||||
@@ -143,19 +146,38 @@ class ScreenRecordManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseParamsObject(paramsJson: String?): JsonObject? {
|
||||
if (paramsJson.isNullOrBlank()) return null
|
||||
return try {
|
||||
Json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
|
||||
params?.get(key) as? JsonPrimitive
|
||||
|
||||
private fun parseDurationMs(params: JsonObject?): Int? =
|
||||
parseJsonInt(params, "durationMs")
|
||||
readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseFps(params: JsonObject?): Double? =
|
||||
parseJsonDouble(params, "fps")
|
||||
readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
|
||||
|
||||
private fun parseScreenIndex(params: JsonObject?): Int? =
|
||||
parseJsonInt(params, "screenIndex")
|
||||
readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
|
||||
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
|
||||
private fun parseIncludeAudio(params: JsonObject?): Boolean? {
|
||||
val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
|
||||
return when (value) {
|
||||
"true" -> true
|
||||
"false" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseString(params: JsonObject?, key: String): String? =
|
||||
parseJsonString(params, key)
|
||||
readPrimitive(params, key)?.contentOrNull
|
||||
|
||||
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
|
||||
val pixels = width.toLong() * height.toLong()
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package ai.openclaw.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal data class Base64ImageState(
|
||||
val image: ImageBitmap?,
|
||||
val failed: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun rememberBase64ImageState(base64: String): Base64ImageState {
|
||||
var image by remember(base64) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
return Base64ImageState(image = image, failed = failed)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package ai.openclaw.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -18,10 +20,15 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
@@ -40,6 +47,8 @@ import ai.openclaw.android.ui.mobileCaption1
|
||||
import ai.openclaw.android.ui.mobileCodeBg
|
||||
import ai.openclaw.android.ui.mobileCodeText
|
||||
import ai.openclaw.android.ui.mobileTextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.commonmark.Extension
|
||||
import org.commonmark.ext.autolink.AutolinkExtension
|
||||
import org.commonmark.ext.gfm.strikethrough.Strikethrough
|
||||
@@ -546,8 +555,23 @@ private data class ParsedDataImage(
|
||||
|
||||
@Composable
|
||||
private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
val imageState = rememberBase64ImageState(base64)
|
||||
val image = imageState.image
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
Image(
|
||||
@@ -556,7 +580,7 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else if (imageState.failed) {
|
||||
} else if (failed) {
|
||||
Text(
|
||||
text = "Image unavailable",
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ai.openclaw.android.ui.chat
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -14,11 +16,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -44,6 +51,8 @@ import ai.openclaw.android.ui.mobileTextSecondary
|
||||
import ai.openclaw.android.ui.mobileWarning
|
||||
import ai.openclaw.android.ui.mobileWarningSoft
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private data class ChatBubbleStyle(
|
||||
val alignEnd: Boolean,
|
||||
@@ -232,8 +241,23 @@ private fun roleLabel(role: String): String {
|
||||
|
||||
@Composable
|
||||
private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
val imageState = rememberBase64ImageState(base64)
|
||||
val image = imageState.image
|
||||
var image by remember(base64) { mutableStateOf<androidx.compose.ui.graphics.ImageBitmap?>(null) }
|
||||
var failed by remember(base64) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(base64) {
|
||||
failed = false
|
||||
image =
|
||||
withContext(Dispatchers.Default) {
|
||||
try {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
||||
bitmap.asImageBitmap()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (image == null) failed = true
|
||||
}
|
||||
|
||||
if (image != null) {
|
||||
Surface(
|
||||
@@ -249,7 +273,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
} else if (imageState.failed) {
|
||||
} else if (failed) {
|
||||
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@ package ai.openclaw.android.gateway
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.Response
|
||||
@@ -29,10 +27,6 @@ import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val TEST_TIMEOUT_MS = 8_000L
|
||||
private const val CONNECT_CHALLENGE_FRAME =
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
@@ -43,150 +37,69 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
}
|
||||
}
|
||||
|
||||
private data class NodeHarness(
|
||||
val session: GatewaySession,
|
||||
val sessionJob: Job,
|
||||
)
|
||||
|
||||
private data class InvokeScenarioResult(
|
||||
val request: GatewaySession.InvokeRequest,
|
||||
val resultParams: JsonObject,
|
||||
)
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewaySessionInvokeTest {
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
|
||||
) {
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
}
|
||||
|
||||
assertEquals("invoke-1", result.request.id)
|
||||
assertEquals("node-1", result.request.nodeId)
|
||||
assertEquals("debug.ping", result.request.command)
|
||||
assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
) {
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
}
|
||||
|
||||
assertEquals("invoke-2", result.request.id)
|
||||
assertEquals("node-2", result.request.nodeId)
|
||||
assertEquals("debug.raw", result.request.command)
|
||||
assertEquals("""{"raw":true}""", result.request.paramsJson)
|
||||
assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val result =
|
||||
runInvokeScenario(
|
||||
invokeEventFrame =
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
) {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
}
|
||||
|
||||
assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = testJson()
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = CompletableDeferred<String?>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
val lastDisconnect = AtomicReference("")
|
||||
|
||||
val server =
|
||||
startGatewayServer(json) { webSocket, id, method, frame ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
|
||||
val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
|
||||
val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
harness.session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun createNodeHarness(
|
||||
connected: CompletableDeferred<Unit>,
|
||||
lastDisconnect: AtomicReference<String>,
|
||||
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
|
||||
): NodeHarness {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
@@ -194,150 +107,460 @@ class GatewaySessionInvokeTest {
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = onInvoke,
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
return NodeHarness(session = session, sessionJob = sessionJob)
|
||||
}
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|$port",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun awaitConnectedOrThrow(
|
||||
connected: CompletableDeferred<Unit>,
|
||||
lastDisconnect: AtomicReference<String>,
|
||||
server: MockWebServer,
|
||||
) {
|
||||
val connectedWithinTimeout =
|
||||
withTimeoutOrNull(TEST_TIMEOUT_MS) {
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-1", req.id)
|
||||
assertEquals("node-1", req.nodeId)
|
||||
assertEquals("debug.ping", req.command)
|
||||
assertEquals("""{"ping":"pong"}""", req.paramsJson)
|
||||
assertNull(handshakeOrigin.get())
|
||||
assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
true,
|
||||
resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
|
||||
harness.session.disconnect()
|
||||
harness.sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private suspend fun runInvokeScenario(
|
||||
invokeEventFrame: String,
|
||||
onHandshake: ((RecordedRequest) -> Unit)? = null,
|
||||
onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
|
||||
): InvokeScenarioResult {
|
||||
val json = testJson()
|
||||
@Test
|
||||
fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeRequest = CompletableDeferred<GatewaySession.InvokeRequest>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
startGatewayServer(
|
||||
json = json,
|
||||
onHandshake = onHandshake,
|
||||
) { webSocket, id, method, frame ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(connectResponseFrame(id))
|
||||
webSocket.send(invokeEventFrame)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
onInvoke(req)
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
if (!invokeRequest.isCompleted) invokeRequest.complete(req)
|
||||
GatewaySession.InvokeResult.ok("""{"handled":true}""")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
connectNodeSession(harness.session, server.port)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val req = withTimeout(8_000) { invokeRequest.await() }
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
return InvokeScenarioResult(request = request, resultParams = resultParams)
|
||||
|
||||
assertEquals("invoke-2", req.id)
|
||||
assertEquals("node-2", req.nodeId)
|
||||
assertEquals("debug.raw", req.command)
|
||||
assertEquals("""{"raw":true}""", req.paramsJson)
|
||||
assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
|
||||
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
}
|
||||
@Test
|
||||
fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val invokeResultParams = CompletableDeferred<String>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
private fun startGatewayServer(
|
||||
json: Json,
|
||||
onHandshake: ((RecordedRequest) -> Unit)? = null,
|
||||
onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
|
||||
): MockWebServer =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
onHandshake?.invoke(request)
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(CONNECT_CHALLENGE_FRAME)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
onRequestFrame(webSocket, id, method, frame)
|
||||
}
|
||||
},
|
||||
)
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
|
||||
)
|
||||
}
|
||||
"node.invoke.result" -> {
|
||||
if (!invokeResultParams.isCompleted) {
|
||||
invokeResultParams.complete(frame["params"]?.toString().orEmpty())
|
||||
}
|
||||
webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
start()
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = {
|
||||
throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
|
||||
val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
|
||||
|
||||
assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
|
||||
assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
|
||||
assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
|
||||
assertEquals(
|
||||
"CAMERA_PERMISSION_REQUIRED",
|
||||
resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
|
||||
)
|
||||
assertEquals(
|
||||
"grant Camera permission",
|
||||
resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val refreshRequestParams = CompletableDeferred<String?>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
MockWebServer().apply {
|
||||
dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
return MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
webSocket.send(
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
val frame = json.parseToJsonElement(text).jsonObject
|
||||
if (frame["type"]?.jsonPrimitive?.content != "req") return
|
||||
val id = frame["id"]?.jsonPrimitive?.content ?: return
|
||||
val method = frame["method"]?.jsonPrimitive?.content ?: return
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
|
||||
)
|
||||
}
|
||||
"node.canvas.capability.refresh" -> {
|
||||
if (!refreshRequestParams.isCompleted) {
|
||||
refreshRequestParams.complete(frame["params"]?.toString())
|
||||
}
|
||||
webSocket.send(
|
||||
"""{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { _, _, _ ->
|
||||
if (!connected.isCompleted) connected.complete(Unit)
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
lastDisconnect.set(message)
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
|
||||
)
|
||||
|
||||
try {
|
||||
session.connect(
|
||||
endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "manual|127.0.0.1|${server.port}",
|
||||
name = "test",
|
||||
host = "127.0.0.1",
|
||||
port = server.port,
|
||||
tlsEnabled = false,
|
||||
),
|
||||
token = "test-token",
|
||||
password = null,
|
||||
options =
|
||||
GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = listOf("node:invoke"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client =
|
||||
GatewayClientInfo(
|
||||
id = "openclaw-android-test",
|
||||
displayName = "Android Test",
|
||||
version = "1.0.0-test",
|
||||
platform = "android",
|
||||
mode = "node",
|
||||
instanceId = "android-test-instance",
|
||||
deviceFamily = "android",
|
||||
modelIdentifier = "test",
|
||||
),
|
||||
),
|
||||
tls = null,
|
||||
)
|
||||
|
||||
val connectedWithinTimeout = withTimeoutOrNull(8_000) {
|
||||
connected.await()
|
||||
true
|
||||
} == true
|
||||
if (!connectedWithinTimeout) {
|
||||
throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
|
||||
}
|
||||
|
||||
val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
|
||||
val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
|
||||
|
||||
assertEquals(true, refreshed)
|
||||
assertEquals("{}", refreshParamsJson)
|
||||
assertEquals(
|
||||
"http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
|
||||
session.currentCanvasHostUrl(),
|
||||
)
|
||||
} finally {
|
||||
session.disconnect()
|
||||
sessionJob.cancelAndJoin()
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
class CalendarHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class CalendarHandlerTest {
|
||||
@Test
|
||||
fun handleCalendarEvents_requiresPermission() {
|
||||
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
|
||||
@@ -79,6 +83,8 @@ class CalendarHandlerTest : NodeHandlerRobolectricTest() {
|
||||
assertFalse(result.ok)
|
||||
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeCalendarDataSource(
|
||||
|
||||
@@ -9,8 +9,12 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
class ContactsHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ContactsHandlerTest {
|
||||
@Test
|
||||
fun handleContactsSearch_requiresReadPermission() {
|
||||
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
|
||||
@@ -88,6 +92,8 @@ class ContactsHandlerTest : NodeHandlerRobolectricTest() {
|
||||
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
|
||||
assertEquals(1, source.addCalls)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeContactsDataSource(
|
||||
|
||||
@@ -16,106 +16,144 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class InvokeCommandRegistryTest {
|
||||
private val coreCapabilities =
|
||||
setOf(
|
||||
OpenClawCapability.Canvas.rawValue,
|
||||
OpenClawCapability.Screen.rawValue,
|
||||
OpenClawCapability.Device.rawValue,
|
||||
OpenClawCapability.Notifications.rawValue,
|
||||
OpenClawCapability.System.rawValue,
|
||||
OpenClawCapability.AppUpdate.rawValue,
|
||||
OpenClawCapability.Photos.rawValue,
|
||||
OpenClawCapability.Contacts.rawValue,
|
||||
OpenClawCapability.Calendar.rawValue,
|
||||
)
|
||||
|
||||
private val optionalCapabilities =
|
||||
setOf(
|
||||
OpenClawCapability.Camera.rawValue,
|
||||
OpenClawCapability.Location.rawValue,
|
||||
OpenClawCapability.Sms.rawValue,
|
||||
OpenClawCapability.VoiceWake.rawValue,
|
||||
OpenClawCapability.Motion.rawValue,
|
||||
)
|
||||
|
||||
private val coreCommands =
|
||||
setOf(
|
||||
OpenClawDeviceCommand.Status.rawValue,
|
||||
OpenClawDeviceCommand.Info.rawValue,
|
||||
OpenClawDeviceCommand.Permissions.rawValue,
|
||||
OpenClawDeviceCommand.Health.rawValue,
|
||||
OpenClawNotificationsCommand.List.rawValue,
|
||||
OpenClawNotificationsCommand.Actions.rawValue,
|
||||
OpenClawSystemCommand.Notify.rawValue,
|
||||
OpenClawPhotosCommand.Latest.rawValue,
|
||||
OpenClawContactsCommand.Search.rawValue,
|
||||
OpenClawContactsCommand.Add.rawValue,
|
||||
OpenClawCalendarCommand.Events.rawValue,
|
||||
OpenClawCalendarCommand.Add.rawValue,
|
||||
"app.update",
|
||||
)
|
||||
|
||||
private val optionalCommands =
|
||||
setOf(
|
||||
OpenClawCameraCommand.Snap.rawValue,
|
||||
OpenClawCameraCommand.Clip.rawValue,
|
||||
OpenClawCameraCommand.List.rawValue,
|
||||
OpenClawLocationCommand.Get.rawValue,
|
||||
OpenClawMotionCommand.Activity.rawValue,
|
||||
OpenClawMotionCommand.Pedometer.rawValue,
|
||||
OpenClawSmsCommand.Send.rawValue,
|
||||
)
|
||||
|
||||
private val debugCommands = setOf("debug.logs", "debug.ed25519")
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_respectsFeatureAvailability() {
|
||||
val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertContainsAll(capabilities, coreCapabilities)
|
||||
assertMissingAll(capabilities, optionalCapabilities)
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
|
||||
val capabilities =
|
||||
InvokeCommandRegistry.advertisedCapabilities(
|
||||
defaultFlags(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = true,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
|
||||
assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_respectsFeatureAvailability() {
|
||||
val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = false,
|
||||
locationEnabled = false,
|
||||
smsAvailable = false,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = false,
|
||||
motionPedometerAvailable = false,
|
||||
debugBuild = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertContainsAll(commands, coreCommands)
|
||||
assertMissingAll(commands, optionalCommands + debugCommands)
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertFalse(commands.contains("debug.logs"))
|
||||
assertFalse(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
|
||||
val commands =
|
||||
InvokeCommandRegistry.advertisedCommands(
|
||||
defaultFlags(
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = true,
|
||||
locationEnabled = true,
|
||||
smsAvailable = true,
|
||||
voiceWakeEnabled = false,
|
||||
motionActivityAvailable = true,
|
||||
motionPedometerAvailable = true,
|
||||
debugBuild = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
|
||||
assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
|
||||
assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
|
||||
assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
|
||||
assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
|
||||
assertTrue(commands.contains("debug.logs"))
|
||||
assertTrue(commands.contains("debug.ed25519"))
|
||||
assertTrue(commands.contains("app.update"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -136,31 +174,4 @@ class InvokeCommandRegistryTest {
|
||||
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
|
||||
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
|
||||
}
|
||||
|
||||
private fun defaultFlags(
|
||||
cameraEnabled: Boolean = false,
|
||||
locationEnabled: Boolean = false,
|
||||
smsAvailable: Boolean = false,
|
||||
voiceWakeEnabled: Boolean = false,
|
||||
motionActivityAvailable: Boolean = false,
|
||||
motionPedometerAvailable: Boolean = false,
|
||||
debugBuild: Boolean = false,
|
||||
): NodeRuntimeFlags =
|
||||
NodeRuntimeFlags(
|
||||
cameraEnabled = cameraEnabled,
|
||||
locationEnabled = locationEnabled,
|
||||
smsAvailable = smsAvailable,
|
||||
voiceWakeEnabled = voiceWakeEnabled,
|
||||
motionActivityAvailable = motionActivityAvailable,
|
||||
motionPedometerAvailable = motionPedometerAvailable,
|
||||
debugBuild = debugBuild,
|
||||
)
|
||||
|
||||
private fun assertContainsAll(actual: List<String>, expected: Set<String>) {
|
||||
expected.forEach { value -> assertTrue(actual.contains(value)) }
|
||||
}
|
||||
|
||||
private fun assertMissingAll(actual: List<String>, forbidden: Set<String>) {
|
||||
forbidden.forEach { value -> assertFalse(actual.contains(value)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
class MotionHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MotionHandlerTest {
|
||||
@Test
|
||||
fun handleMotionActivity_requiresPermission() =
|
||||
runTest {
|
||||
@@ -82,6 +86,8 @@ class MotionHandlerTest : NodeHandlerRobolectricTest() {
|
||||
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
|
||||
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakeMotionDataSource(
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
abstract class NodeHandlerRobolectricTest {
|
||||
protected fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
@@ -10,8 +10,12 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
class PhotosHandlerTest : NodeHandlerRobolectricTest() {
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PhotosHandlerTest {
|
||||
@Test
|
||||
fun handlePhotosLatest_requiresPermission() {
|
||||
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
|
||||
@@ -59,6 +63,8 @@ class PhotosHandlerTest : NodeHandlerRobolectricTest() {
|
||||
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
|
||||
assertEquals(640, first.getValue("width").jsonPrimitive.int)
|
||||
}
|
||||
|
||||
private fun appContext(): Context = RuntimeEnvironment.getApplication()
|
||||
}
|
||||
|
||||
private class FakePhotosDataSource(
|
||||
|
||||
@@ -52,27 +52,46 @@ actor CameraController {
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: params.deviceId,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: { setupError in
|
||||
CameraError.captureFailed(setupError.localizedDescription)
|
||||
})
|
||||
let session = prepared.session
|
||||
let output = prepared.output
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCapturePhotoOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add photo output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxPhotoQualityPrioritization = .quality
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await CameraCapturePipelineSupport.warmUpCaptureSession()
|
||||
await Self.warmUpCaptureSession()
|
||||
await Self.sleepDelayMs(delayMs)
|
||||
|
||||
let rawData = try await CameraCapturePipelineSupport.capturePhotoData(output: output) { continuation in
|
||||
PhotoCaptureDelegate(continuation)
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||
}
|
||||
return AVCapturePhotoSettings()
|
||||
}()
|
||||
settings.photoQualityPrioritization = .quality
|
||||
|
||||
var delegate: PhotoCaptureDelegate?
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
let d = PhotoCaptureDelegate(cont)
|
||||
delegate = d
|
||||
output.capturePhoto(with: settings, delegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
@@ -102,36 +121,63 @@ actor CameraController {
|
||||
try await self.ensureAccess(for: .audio)
|
||||
}
|
||||
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
guard session.canAddInput(cameraInput) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(cameraInput)
|
||||
|
||||
if includeAudio {
|
||||
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||
throw CameraError.microphoneUnavailable
|
||||
}
|
||||
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||
if session.canAddInput(micInput) {
|
||||
session.addInput(micInput)
|
||||
} else {
|
||||
throw CameraError.captureFailed("Failed to add microphone input")
|
||||
}
|
||||
}
|
||||
|
||||
let output = AVCaptureMovieFileOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add movie output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let movURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
||||
let mp4URL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
||||
|
||||
defer {
|
||||
try? FileManager().removeItem(at: movURL)
|
||||
try? FileManager().removeItem(at: mp4URL)
|
||||
}
|
||||
|
||||
let data = try await CameraCapturePipelineSupport.withWarmMovieSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: params.deviceId,
|
||||
includeAudio: includeAudio,
|
||||
durationMs: durationMs,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: Self.mapMovieSetupError) { output in
|
||||
var delegate: MovieFileDelegate?
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||
let d = MovieFileDelegate(cont)
|
||||
delegate = d
|
||||
output.startRecording(to: movURL, recordingDelegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||
return try Data(contentsOf: mp4URL)
|
||||
}
|
||||
var delegate: MovieFileDelegate?
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||
let d = MovieFileDelegate(cont)
|
||||
delegate = d
|
||||
output.startRecording(to: movURL, recordingDelegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||
|
||||
let data = try Data(contentsOf: mp4URL)
|
||||
return (
|
||||
format: format.rawValue,
|
||||
base64: data.base64EncodedString(),
|
||||
@@ -150,7 +196,22 @@ actor CameraController {
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
@unknown default:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
}
|
||||
@@ -172,15 +233,12 @@ actor CameraController {
|
||||
return AVCaptureDevice.default(for: .video)
|
||||
}
|
||||
|
||||
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
|
||||
CameraCapturePipelineSupport.mapMovieSetupError(
|
||||
setupError,
|
||||
microphoneUnavailableError: .microphoneUnavailable,
|
||||
captureFailed: { .captureFailed($0) })
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
CameraCapturePipelineSupport.positionLabel(position)
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
|
||||
@@ -249,6 +307,11 @@ actor CameraController {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func warmUpCaptureSession() async {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
|
||||
guard delayMs > 0 else { return }
|
||||
let maxDelayMs = 10 * 1000
|
||||
|
||||
@@ -15,7 +15,14 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
|
||||
let store = try await Self.authorizedStore()
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 25, 200))
|
||||
|
||||
@@ -40,7 +47,14 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
|
||||
let store = try await Self.authorizedStore()
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
|
||||
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -113,18 +127,6 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
}
|
||||
|
||||
private static func authorizedStore() async throws -> CNContactStore {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||
(values ?? [])
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
|
||||
@@ -53,17 +53,23 @@ final class GatewayDiscoveryModel {
|
||||
self.appendDebugLog("start()")
|
||||
|
||||
for domain in OpenClawBonjour.gatewayServiceDomains {
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain),
|
||||
using: params)
|
||||
|
||||
browser.stateUpdateHandler = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
self.updateStatusText()
|
||||
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
}
|
||||
}
|
||||
|
||||
browser.browseResultsChangedHandler = { [weak self] results, _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||
switch result.endpoint {
|
||||
@@ -92,10 +98,13 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
|
||||
self.recomputeGateways()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
self.browsers[domain] = browser
|
||||
browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// NetService-based resolver for Bonjour services.
|
||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
@@ -21,7 +20,8 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
}
|
||||
|
||||
func start(timeout: TimeInterval = 2.0) {
|
||||
BonjourServiceResolverSupport.start(self.service, timeout: timeout)
|
||||
self.service.schedule(in: .main, forMode: .common)
|
||||
self.service.resolve(withTimeout: timeout)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
@@ -47,6 +47,9 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
}
|
||||
|
||||
private static func normalizeHost(_ raw: String?) -> String? {
|
||||
BonjourServiceResolverSupport.normalizeHost(raw)
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import CoreLocation
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
@@ -17,18 +17,21 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
var locationManager: CLLocationManager {
|
||||
self.manager
|
||||
}
|
||||
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? {
|
||||
get { self.locationContinuation }
|
||||
set { self.locationContinuation = newValue }
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.configureLocationManager()
|
||||
self.manager.delegate = self
|
||||
self.manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func authorizationStatus() -> CLAuthorizationStatus {
|
||||
self.manager.authorizationStatus
|
||||
}
|
||||
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
if #available(iOS 14.0, *) {
|
||||
return self.manager.accuracyAuthorization
|
||||
}
|
||||
return .fullAccuracy
|
||||
}
|
||||
|
||||
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus {
|
||||
@@ -59,14 +62,25 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
{
|
||||
_ = params
|
||||
return try await LocationCurrentRequest.resolve(
|
||||
manager: self.manager,
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs,
|
||||
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
|
||||
try await self.withTimeout(timeoutMs: timeoutMs, operation: operation)
|
||||
let now = Date()
|
||||
if let maxAgeMs,
|
||||
let cached = self.manager.location,
|
||||
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
|
||||
{
|
||||
return cached
|
||||
}
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
let timeout = max(0, timeoutMs ?? 10000)
|
||||
return try await self.withTimeout(timeoutMs: timeout) {
|
||||
try await self.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.locationContinuation = cont
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +97,24 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
|
||||
switch accuracy {
|
||||
case .coarse:
|
||||
kCLLocationAccuracyKilometer
|
||||
case .balanced:
|
||||
kCLLocationAccuracyHundredMeters
|
||||
case .precise:
|
||||
kCLLocationAccuracyBest
|
||||
}
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OpenClawKit
|
||||
import os
|
||||
|
||||
extension NodeAppModel {
|
||||
@@ -12,12 +11,24 @@ extension NodeAppModel {
|
||||
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
if let host = base.host, LoopbackHost.isLoopback(host) {
|
||||
if let host = base.host, Self.isLoopbackHost(host) {
|
||||
return nil
|
||||
}
|
||||
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func showA2UIOnConnectIfNeeded() async {
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -41,17 +41,15 @@ private struct AutoDetectStep: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: self.connectionStatusLines(),
|
||||
secondaryLine: self.connectStatusText)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.resetConnectionState()
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
@@ -96,6 +94,15 @@ private struct AutoDetectStep: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func connectionStatusLines() -> [String] {
|
||||
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||
}
|
||||
|
||||
private func resetConnectionState() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectStatusText = nil
|
||||
self.connectingGatewayID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@@ -155,10 +162,11 @@ private struct ManualEntryStep: View {
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
gatewayConnectionStatusSection(
|
||||
appModel: self.appModel,
|
||||
gatewayController: self.gatewayController,
|
||||
secondaryLine: self.connectStatusText)
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: self.connectionStatusLines(),
|
||||
secondaryLine: self.connectStatusText)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
@@ -177,10 +185,7 @@ private struct ManualEntryStep: View {
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button("Retry") {
|
||||
resetGatewayConnectionState(
|
||||
appModel: self.appModel,
|
||||
connectStatusText: &self.connectStatusText,
|
||||
connectingGatewayID: &self.connectingGatewayID)
|
||||
self.resetConnectionState()
|
||||
self.resetManualForm()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
@@ -232,6 +237,16 @@ private struct ManualEntryStep: View {
|
||||
return Int(trimmed.filter { $0.isNumber })
|
||||
}
|
||||
|
||||
private func connectionStatusLines() -> [String] {
|
||||
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||
}
|
||||
|
||||
private func resetConnectionState() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectStatusText = nil
|
||||
self.connectingGatewayID = nil
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
@@ -302,38 +317,6 @@ private struct ManualEntryStep: View {
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
}
|
||||
|
||||
private func gatewayConnectionStatusLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
{
|
||||
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
|
||||
}
|
||||
|
||||
private func resetGatewayConnectionState(
|
||||
appModel: NodeAppModel,
|
||||
connectStatusText: inout String?,
|
||||
connectingGatewayID: inout String?)
|
||||
{
|
||||
appModel.disconnectGateway()
|
||||
connectStatusText = nil
|
||||
connectingGatewayID = nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController,
|
||||
secondaryLine: String?) -> some View
|
||||
{
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: gatewayConnectionStatusLines(
|
||||
appModel: appModel,
|
||||
gatewayController: gatewayController),
|
||||
secondaryLine: secondaryLine)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
let statusLines: [String]
|
||||
let secondaryLine: String?
|
||||
|
||||
@@ -489,7 +489,21 @@ struct OnboardingWizardView: View {
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
self.manualConnectButton
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
} header: {
|
||||
Text("Developer Local")
|
||||
} footer: {
|
||||
@@ -617,25 +631,22 @@ struct OnboardingWizardView: View {
|
||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
self.manualConnectButton
|
||||
}
|
||||
}
|
||||
|
||||
private var manualConnectButton: some View {
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
}
|
||||
|
||||
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
|
||||
|
||||
@@ -456,7 +456,11 @@ enum WatchPromptNotificationBridge {
|
||||
) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
center.add(request) { error in
|
||||
ThrowingContinuationSupport.resumeVoid(continuation, error: error)
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,20 @@ struct RootCanvas: View {
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
return .connecting
|
||||
}
|
||||
|
||||
if text.localizedCaseInsensitiveContains("error") {
|
||||
return .error
|
||||
}
|
||||
|
||||
return .disconnected
|
||||
}
|
||||
|
||||
private func updateIdleTimer() {
|
||||
@@ -330,18 +343,82 @@ private struct CanvasContent: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.gatewayActionsDialog(
|
||||
.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.openSettings() })
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
self.openSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.cameraHUDText,
|
||||
cameraHUDKind: self.cameraHUDKind)
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,14 +70,38 @@ struct RootTabs: View {
|
||||
self.toastDismissTask?.cancel()
|
||||
self.toastDismissTask = nil
|
||||
}
|
||||
.gatewayActionsDialog(
|
||||
.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = 2 })
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
if self.appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
return .connecting
|
||||
}
|
||||
|
||||
if text.localizedCaseInsensitiveContains("error") {
|
||||
return .error
|
||||
}
|
||||
|
||||
return .disconnected
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
|
||||
@@ -35,7 +35,7 @@ final class ScreenController {
|
||||
if let url = URL(string: trimmed),
|
||||
!url.isFileURL,
|
||||
let host = url.host,
|
||||
LoopbackHost.isLoopback(host)
|
||||
Self.isLoopbackHost(host)
|
||||
{
|
||||
// Never try to load loopback URLs from a remote gateway.
|
||||
self.showDefaultCanvas()
|
||||
@@ -87,11 +87,25 @@ final class ScreenController {
|
||||
|
||||
func applyDebugStatusIfNeeded() {
|
||||
guard let webView = self.activeWebView else { return }
|
||||
WebViewJavaScriptSupport.applyDebugStatus(
|
||||
webView: webView,
|
||||
enabled: self.debugStatusEnabled,
|
||||
title: self.debugStatusTitle,
|
||||
subtitle: self.debugStatusSubtitle)
|
||||
let enabled = self.debugStatusEnabled
|
||||
let title = self.debugStatusTitle
|
||||
let subtitle = self.debugStatusSubtitle
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||
}
|
||||
if (!\(enabled ? "true" : "false")) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
|
||||
}
|
||||
} catch (_) {}
|
||||
})()
|
||||
"""
|
||||
webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
|
||||
@@ -123,43 +137,22 @@ final class ScreenController {
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
|
||||
let image = try await self.snapshotImage(maxWidth: maxWidth)
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
func snapshotBase64(
|
||||
maxWidth: CGFloat? = nil,
|
||||
format: OpenClawCanvasSnapshotFormat,
|
||||
quality: Double? = nil) async throws -> String
|
||||
{
|
||||
let image = try await self.snapshotImage(maxWidth: maxWidth)
|
||||
|
||||
let data: Data?
|
||||
switch format {
|
||||
case .png:
|
||||
data = image.pngData()
|
||||
case .jpeg:
|
||||
let q = (quality ?? 0.82).clamped(to: 0.1...1.0)
|
||||
data = image.jpegData(compressionQuality: q)
|
||||
}
|
||||
guard let data else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
private func snapshotImage(maxWidth: CGFloat?) async throws -> UIImage {
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
@@ -184,7 +177,58 @@ final class ScreenController {
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
return image
|
||||
guard let data = image.pngData() else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
func snapshotBase64(
|
||||
maxWidth: CGFloat? = nil,
|
||||
format: OpenClawCanvasSnapshotFormat,
|
||||
quality: Double? = nil) async throws -> String
|
||||
{
|
||||
let config = WKSnapshotConfiguration()
|
||||
if let maxWidth {
|
||||
config.snapshotWidth = NSNumber(value: Double(maxWidth))
|
||||
}
|
||||
guard let webView = self.activeWebView else {
|
||||
throw NSError(domain: "Screen", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "web view unavailable",
|
||||
])
|
||||
}
|
||||
let image: UIImage = try await withCheckedThrowingContinuation { cont in
|
||||
webView.takeSnapshot(with: config) { image, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let image else {
|
||||
cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot failed",
|
||||
]))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: image)
|
||||
}
|
||||
}
|
||||
|
||||
let data: Data?
|
||||
switch format {
|
||||
case .png:
|
||||
data = image.pngData()
|
||||
case .jpeg:
|
||||
let q = (quality ?? 0.82).clamped(to: 0.1...1.0)
|
||||
data = image.jpegData(compressionQuality: q)
|
||||
}
|
||||
guard let data else {
|
||||
throw NSError(domain: "Screen", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "snapshot encode failed",
|
||||
])
|
||||
}
|
||||
return data.base64EncodedString()
|
||||
}
|
||||
|
||||
func attachWebView(_ webView: WKWebView) {
|
||||
@@ -214,6 +258,17 @@ final class ScreenController {
|
||||
ext: "html",
|
||||
subdirectory: "CanvasScaffold")
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
let std = url.standardizedFileURL
|
||||
@@ -235,8 +290,59 @@ final class ScreenController {
|
||||
scrollView.bounces = allowScroll
|
||||
}
|
||||
|
||||
private static func jsValue(_ value: String?) -> String {
|
||||
guard let value else { return "null" }
|
||||
if let data = try? JSONSerialization.data(withJSONObject: [value]),
|
||||
let encoded = String(data: data, encoding: .utf8),
|
||||
encoded.count >= 2
|
||||
{
|
||||
return String(encoded.dropFirst().dropLast())
|
||||
}
|
||||
return "null"
|
||||
}
|
||||
|
||||
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if host.hasSuffix(".ts.net") { return true }
|
||||
if host.hasSuffix(".tailscale.net") { return true }
|
||||
// Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
|
||||
if !host.contains("."), !host.contains(":") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
// 10.0.0.0/8
|
||||
if a == 10 { return true }
|
||||
// 172.16.0.0/12
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
// 192.168.0.0/16
|
||||
if a == 192, b == 168 { return true }
|
||||
// 127.0.0.0/8
|
||||
if a == 127 { return true }
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if a == 169, b == 254 { return true }
|
||||
// Tailscale: 100.64.0.0/10
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? {
|
||||
|
||||
@@ -84,8 +84,8 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
throw ScreenRecordError.invalidScreenIndex(idx)
|
||||
}
|
||||
|
||||
let durationMs = CaptureRateLimits.clampDurationMs(durationMs)
|
||||
let fps = CaptureRateLimits.clampFps(fps, maxFps: 30)
|
||||
let durationMs = Self.clampDurationMs(durationMs)
|
||||
let fps = Self.clampFps(fps)
|
||||
let fpsInt = Int32(fps.rounded())
|
||||
let fpsValue = Double(fpsInt)
|
||||
let includeAudio = includeAudio ?? true
|
||||
@@ -319,6 +319,16 @@ final class ScreenRecordService: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
|
||||
let v = ms ?? 10000
|
||||
return min(60000, max(250, v))
|
||||
}
|
||||
|
||||
private nonisolated static func clampFps(_ fps: Double?) -> Double {
|
||||
let v = fps ?? 10
|
||||
if !v.isFinite { return 10 }
|
||||
return min(30, max(1, v))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -340,11 +350,11 @@ private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) ->
|
||||
#if DEBUG
|
||||
extension ScreenRecordService {
|
||||
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
|
||||
CaptureRateLimits.clampDurationMs(ms)
|
||||
self.clampDurationMs(ms)
|
||||
}
|
||||
|
||||
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
|
||||
CaptureRateLimits.clampFps(fps, maxFps: 30)
|
||||
self.clampFps(fps)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func gatewayActionsDialog(
|
||||
isPresented: Binding<Bool>,
|
||||
onDisconnect: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void) -> some View
|
||||
{
|
||||
self.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
onDisconnect()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
onOpenSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayStatusBuilder {
|
||||
@MainActor
|
||||
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
|
||||
if appModel.gatewayServerName != nil { return .connected }
|
||||
|
||||
let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if text.localizedCaseInsensitiveContains("connecting") ||
|
||||
text.localizedCaseInsensitiveContains("reconnecting")
|
||||
{
|
||||
return .connecting
|
||||
}
|
||||
|
||||
if text.localizedCaseInsensitiveContains("error") {
|
||||
return .error
|
||||
}
|
||||
|
||||
return .disconnected
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct StatusGlassCardModifier: ViewModifier {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
let brighten: Bool
|
||||
let verticalPadding: CGFloat
|
||||
let horizontalPadding: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(.vertical, self.verticalPadding)
|
||||
.padding(.horizontal, self.horizontalPadding)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View {
|
||||
self.modifier(
|
||||
StatusGlassCardModifier(
|
||||
brighten: brighten,
|
||||
verticalPadding: verticalPadding,
|
||||
horizontalPadding: horizontalPadding
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
@@ -85,7 +86,20 @@ struct StatusPill: View {
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 8)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeToast: View {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var command: String
|
||||
var brighten: Bool = false
|
||||
|
||||
@@ -16,7 +18,20 @@ struct VoiceWakeToast: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 10)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.accessibilityLabel("Voice Wake triggered")
|
||||
.accessibilityValue("Command: \(self.command)")
|
||||
}
|
||||
|
||||
@@ -216,7 +216,22 @@ final class VoiceWakeManager: NSObject {
|
||||
self.isEnabled = false
|
||||
self.isListening = false
|
||||
self.statusText = "Off"
|
||||
self.tearDownRecognitionPipeline()
|
||||
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
|
||||
@@ -226,7 +241,22 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
self.isListening = false
|
||||
self.statusText = "Paused"
|
||||
self.tearDownRecognitionPipeline()
|
||||
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -280,24 +310,6 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func tearDownRecognitionPipeline() {
|
||||
self.tapDrainTask?.cancel()
|
||||
self.tapDrainTask = nil
|
||||
self.tapQueue?.clear()
|
||||
self.tapQueue = nil
|
||||
|
||||
self.recognitionTask?.cancel()
|
||||
self.recognitionTask = nil
|
||||
self.recognitionRequest = nil
|
||||
|
||||
if self.audioEngine.isRunning {
|
||||
self.audioEngine.stop()
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
}
|
||||
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
|
||||
{ [weak self] result, error in
|
||||
let transcript = result?.bestTranscription.formattedString
|
||||
@@ -392,10 +404,16 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
|
||||
private nonisolated static func microphonePermissionMessage(kind: String) -> String {
|
||||
let status = AVAudioApplication.shared.recordPermission
|
||||
return self.deniedByDefaultPermissionMessage(
|
||||
kind: kind,
|
||||
isUndetermined: status == .undetermined)
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
@@ -445,7 +463,16 @@ final class VoiceWakeManager: NSObject {
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
self.deniedByDefaultPermissionMessage(kind: kind, isUndetermined: status == .undetermined)
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
@@ -465,13 +492,6 @@ final class VoiceWakeManager: NSObject {
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
|
||||
if isUndetermined {
|
||||
return "\(kind) permission not granted"
|
||||
}
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -2,36 +2,6 @@ import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
private func setupCode(from payload: String) -> String {
|
||||
Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private func agentAction(
|
||||
message: String,
|
||||
sessionKey: String? = nil,
|
||||
thinking: String? = nil,
|
||||
deliver: Bool = false,
|
||||
to: String? = nil,
|
||||
channel: String? = nil,
|
||||
timeoutSeconds: Int? = nil,
|
||||
key: String? = nil) -> DeepLinkRoute
|
||||
{
|
||||
.agent(
|
||||
.init(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: deliver,
|
||||
to: to,
|
||||
channel: channel,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
key: key))
|
||||
}
|
||||
|
||||
@Suite struct DeepLinkParserTests {
|
||||
@Test func parseRejectsUnknownHost() {
|
||||
let url = URL(string: "openclaw://nope?message=hi")!
|
||||
@@ -40,7 +10,15 @@ private func agentAction(
|
||||
|
||||
@Test func parseHostIsCaseInsensitive() {
|
||||
let url = URL(string: "openclaw://AGENT?message=Hello")!
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
|
||||
#expect(DeepLinkParser.parse(url) == .agent(.init(
|
||||
message: "Hello",
|
||||
sessionKey: nil,
|
||||
thinking: nil,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
}
|
||||
|
||||
@Test func parseRejectsNonOpenClawScheme() {
|
||||
@@ -56,29 +34,47 @@ private func agentAction(
|
||||
@Test func parseAgentLinkParsesCommonFields() {
|
||||
let url =
|
||||
URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")!
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(
|
||||
message: "Hello",
|
||||
sessionKey: "node-test",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
timeoutSeconds: 30))
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .agent(
|
||||
.init(
|
||||
message: "Hello",
|
||||
sessionKey: "node-test",
|
||||
thinking: "low",
|
||||
deliver: true,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: 30,
|
||||
key: nil)))
|
||||
}
|
||||
|
||||
@Test func parseAgentLinkParsesTargetRoutingFields() {
|
||||
let url =
|
||||
URL(
|
||||
string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(
|
||||
message: "Hello World",
|
||||
deliver: true,
|
||||
to: "+15551234567",
|
||||
channel: "whatsapp",
|
||||
key: "secret"))
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .agent(
|
||||
.init(
|
||||
message: "Hello World",
|
||||
sessionKey: nil,
|
||||
thinking: nil,
|
||||
deliver: true,
|
||||
to: "+15551234567",
|
||||
channel: "whatsapp",
|
||||
timeoutSeconds: nil,
|
||||
key: "secret")))
|
||||
}
|
||||
|
||||
@Test func parseRejectsNegativeTimeoutSeconds() {
|
||||
let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")!
|
||||
#expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
|
||||
#expect(DeepLinkParser.parse(url) == .agent(.init(
|
||||
message: "Hello",
|
||||
sessionKey: nil,
|
||||
thinking: nil,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: nil,
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkParsesCommonFields() {
|
||||
@@ -103,7 +99,13 @@ private func agentAction(
|
||||
|
||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
@@ -119,7 +121,13 @@ private func agentAction(
|
||||
|
||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
||||
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
@@ -131,19 +139,37 @@ private func agentAction(
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
|
||||
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
#expect(link == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
|
||||
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
#expect(link == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
|
||||
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "127.0.0.1",
|
||||
|
||||
@@ -4,6 +4,31 @@ import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControllerTests {
|
||||
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
@@ -5,32 +5,6 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionSecurityTests {
|
||||
private func makeController() -> GatewayConnectionController {
|
||||
GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
|
||||
}
|
||||
|
||||
private func makeDiscoveredGateway(
|
||||
stableID: String,
|
||||
lanHost: String?,
|
||||
tailnetDns: String?,
|
||||
gatewayPort: Int?,
|
||||
fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
|
||||
{
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
return GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
gatewayPort: gatewayPort,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: fingerprint,
|
||||
cliPath: nil)
|
||||
}
|
||||
|
||||
private func clearTLSFingerprint(stableID: String) {
|
||||
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
|
||||
suite.removeObject(forKey: "gateway.tls.\(stableID)")
|
||||
@@ -43,13 +17,22 @@ import Testing
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
||||
|
||||
let gateway = makeDiscoveredGateway(
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "evil.example.com",
|
||||
tailnetDns: "evil.example.com",
|
||||
gatewayPort: 12345,
|
||||
fingerprint: "22")
|
||||
let controller = makeController()
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == "11")
|
||||
@@ -61,13 +44,22 @@ import Testing
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let gateway = makeDiscoveredGateway(
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
fingerprint: "22")
|
||||
let controller = makeController()
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == nil)
|
||||
@@ -90,13 +82,22 @@ import Testing
|
||||
defaults.removeObject(forKey: "gateway.preferredStableID")
|
||||
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
|
||||
let gateway = makeDiscoveredGateway(
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "test.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
fingerprint: nil)
|
||||
let controller = makeController()
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
controller._test_setGateways([gateway])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
@@ -104,7 +105,8 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
|
||||
let controller = makeController()
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
|
||||
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true)
|
||||
@@ -119,7 +121,8 @@ import Testing
|
||||
}
|
||||
|
||||
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
|
||||
let controller = makeController()
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
|
||||
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
|
||||
|
||||
@@ -14,19 +14,6 @@ private let instanceIdEntry = KeychainEntry(service: nodeService, account: "inst
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
|
||||
private let bootstrapDefaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
private let bootstrapKeychainEntries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
private let lastGatewayDefaultsKeys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -74,112 +61,142 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
applyKeychain(snapshot)
|
||||
}
|
||||
|
||||
private func withBootstrapSnapshots(_ body: () -> Void) {
|
||||
let defaultsSnapshot = snapshotDefaults(bootstrapDefaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(bootstrapKeychainEntries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
let snapshot = snapshotDefaults(lastGatewayDefaultsKeys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
body()
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewaySettingsStoreTests {
|
||||
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
|
||||
withBootstrapSnapshots {
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"gateway.preferredStableID": "preferred-test",
|
||||
"gateway.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredGatewayEntry: nil,
|
||||
lastGatewayEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": "node-test",
|
||||
"gateway.preferredStableID": "preferred-test",
|
||||
"gateway.lastDiscoveredStableID": "last-test",
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: nil,
|
||||
preferredGatewayEntry: nil,
|
||||
lastGatewayEntry: nil,
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
#expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
|
||||
#expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
|
||||
}
|
||||
|
||||
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
|
||||
withBootstrapSnapshots {
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"gateway.preferredStableID": nil,
|
||||
"gateway.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredGatewayEntry: "preferred-from-keychain",
|
||||
lastGatewayEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
let defaultsKeys = [
|
||||
"node.instanceId",
|
||||
"gateway.preferredStableID",
|
||||
"gateway.lastDiscoveredStableID",
|
||||
]
|
||||
let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
|
||||
let defaultsSnapshot = snapshotDefaults(defaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain(entries)
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
|
||||
applyDefaults([
|
||||
"node.instanceId": nil,
|
||||
"gateway.preferredStableID": nil,
|
||||
"gateway.lastDiscoveredStableID": nil,
|
||||
])
|
||||
applyKeychain([
|
||||
instanceIdEntry: "node-from-keychain",
|
||||
preferredGatewayEntry: "preferred-from-keychain",
|
||||
lastGatewayEntry: "last-from-keychain",
|
||||
])
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|example.com|443")
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
|
||||
}
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|example.com|443")
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
|
||||
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|example.org|18789",
|
||||
])
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
}
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|example.org|18789",
|
||||
])
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
}
|
||||
|
||||
@Test func talkProviderApiKey_genericRoundTrip() {
|
||||
|
||||
@@ -4,6 +4,31 @@ import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
|
||||
private func makeAgentDeepLinkURL(
|
||||
message: String,
|
||||
deliver: Bool = false,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
|
||||
let defaults = UserDefaults.standard
|
||||
var snapshot: [String: Any?] = [:]
|
||||
for key in updates.keys {
|
||||
snapshot[key] = defaults.object(forKey: key)
|
||||
}
|
||||
for (key, value) in updates {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
defer {
|
||||
for (key, value) in snapshot {
|
||||
if let value {
|
||||
defaults.set(value, forKey: key)
|
||||
} else {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return try body()
|
||||
}
|
||||
@@ -3,19 +3,6 @@ import SwabbleKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
private let openclawTranscript = "hey openclaw do thing"
|
||||
|
||||
private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegment] {
|
||||
makeSegments(
|
||||
transcript: openclawTranscript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", postTriggerStart, 0.1),
|
||||
("thing", postTriggerStart + 0.2, 0.1),
|
||||
])
|
||||
}
|
||||
|
||||
@Suite struct VoiceWakeManagerExtractCommandTests {
|
||||
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
|
||||
let transcript = "hello world"
|
||||
@@ -26,9 +13,17 @@ private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegmen
|
||||
}
|
||||
|
||||
@Test func extractCommandTrimsTokensAndResult() {
|
||||
let segments = openclawSegments(postTriggerStart: 0.9)
|
||||
let transcript = "hey openclaw do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let cmd = VoiceWakeManager.extractCommand(
|
||||
from: openclawTranscript,
|
||||
from: transcript,
|
||||
segments: segments,
|
||||
triggers: [" openclaw "],
|
||||
minPostTriggerGap: 0.3)
|
||||
@@ -36,9 +31,17 @@ private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegmen
|
||||
}
|
||||
|
||||
@Test func extractCommandReturnsNilWhenGapTooShort() {
|
||||
let segments = openclawSegments(postTriggerStart: 0.35)
|
||||
let transcript = "hey openclaw do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", 0.35, 0.1),
|
||||
("thing", 0.5, 0.1),
|
||||
])
|
||||
let cmd = VoiceWakeManager.extractCommand(
|
||||
from: openclawTranscript,
|
||||
from: transcript,
|
||||
segments: segments,
|
||||
triggers: ["openclaw"],
|
||||
minPostTriggerGap: 0.3)
|
||||
@@ -54,9 +57,17 @@ private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegmen
|
||||
}
|
||||
|
||||
@Test func extractCommandIgnoresEmptyTriggers() {
|
||||
let segments = openclawSegments(postTriggerStart: 0.9)
|
||||
let transcript = "hey openclaw do thing"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("openclaw", 0.2, 0.1),
|
||||
("do", 0.9, 0.1),
|
||||
("thing", 1.1, 0.1),
|
||||
])
|
||||
let cmd = VoiceWakeManager.extractCommand(
|
||||
from: openclawTranscript,
|
||||
from: transcript,
|
||||
segments: segments,
|
||||
triggers: ["", " ", "openclaw"],
|
||||
minPostTriggerGap: 0.3)
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum AgentWorkspaceConfig {
|
||||
static func workspace(from root: [String: Any]) -> String? {
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let defaults = agents?["defaults"] as? [String: Any]
|
||||
return defaults?["workspace"] as? String
|
||||
}
|
||||
|
||||
static func setWorkspace(in root: inout [String: Any], workspace: String?) {
|
||||
var agents = root["agents"] as? [String: Any] ?? [:]
|
||||
var defaults = agents["defaults"] as? [String: Any] ?? [:]
|
||||
let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
defaults.removeValue(forKey: "workspace")
|
||||
} else {
|
||||
defaults["workspace"] = trimmed
|
||||
}
|
||||
if defaults.isEmpty {
|
||||
agents.removeValue(forKey: "defaults")
|
||||
} else {
|
||||
agents["defaults"] = defaults
|
||||
}
|
||||
if agents.isEmpty {
|
||||
root.removeValue(forKey: "agents")
|
||||
} else {
|
||||
root["agents"] = agents
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,21 @@ final class AudioInputDeviceObserver {
|
||||
private var defaultInputListener: AudioObjectPropertyListenerBlock?
|
||||
|
||||
static func defaultInputDeviceUID() -> String? {
|
||||
guard let deviceID = self.defaultInputDeviceID() else { return nil }
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var deviceID = AudioObjectID(0)
|
||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
systemObject,
|
||||
&address,
|
||||
0,
|
||||
nil,
|
||||
&size,
|
||||
&deviceID)
|
||||
guard status == noErr, deviceID != 0 else { return nil }
|
||||
return self.deviceUID(for: deviceID)
|
||||
}
|
||||
|
||||
@@ -49,15 +63,6 @@ final class AudioInputDeviceObserver {
|
||||
}
|
||||
|
||||
static func defaultInputDeviceSummary() -> String {
|
||||
guard let deviceID = self.defaultInputDeviceID() else {
|
||||
return "defaultInput=unknown"
|
||||
}
|
||||
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
||||
let name = self.deviceName(for: deviceID) ?? "unknown"
|
||||
return "defaultInput=\(name) (\(uid))"
|
||||
}
|
||||
|
||||
private static func defaultInputDeviceID() -> AudioObjectID? {
|
||||
let systemObject = AudioObjectID(kAudioObjectSystemObject)
|
||||
var address = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
@@ -72,8 +77,12 @@ final class AudioInputDeviceObserver {
|
||||
nil,
|
||||
&size,
|
||||
&deviceID)
|
||||
guard status == noErr, deviceID != 0 else { return nil }
|
||||
return deviceID
|
||||
guard status == noErr, deviceID != 0 else {
|
||||
return "defaultInput=unknown"
|
||||
}
|
||||
let uid = self.deviceUID(for: deviceID) ?? "unknown"
|
||||
let name = self.deviceName(for: deviceID) ?? "unknown"
|
||||
return "defaultInput=\(name) (\(uid))"
|
||||
}
|
||||
|
||||
func start(onChange: @escaping @Sendable () -> Void) {
|
||||
|
||||
@@ -64,33 +64,45 @@ actor CameraCaptureService {
|
||||
|
||||
try await self.ensureAccess(for: .video)
|
||||
|
||||
let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: deviceId,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: { setupError in
|
||||
CameraError.captureFailed(setupError.localizedDescription)
|
||||
})
|
||||
let session = prepared.session
|
||||
let device = prepared.device
|
||||
let output = prepared.output
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .photo
|
||||
|
||||
guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCapturePhotoOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add photo output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxPhotoQualityPrioritization = .quality
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await CameraCapturePipelineSupport.warmUpCaptureSession()
|
||||
await Self.warmUpCaptureSession()
|
||||
await self.waitForExposureAndWhiteBalance(device: device)
|
||||
await self.sleepDelayMs(delayMs)
|
||||
|
||||
let settings: AVCapturePhotoSettings = {
|
||||
if output.availablePhotoCodecTypes.contains(.jpeg) {
|
||||
return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
|
||||
}
|
||||
return AVCapturePhotoSettings()
|
||||
}()
|
||||
settings.photoQualityPrioritization = .quality
|
||||
|
||||
var delegate: PhotoCaptureDelegate?
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { continuation in
|
||||
let captureDelegate = PhotoCaptureDelegate(continuation)
|
||||
delegate = captureDelegate
|
||||
output.capturePhoto(
|
||||
with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
|
||||
delegate: captureDelegate)
|
||||
let rawData: Data = try await withCheckedThrowingContinuation { cont in
|
||||
let d = PhotoCaptureDelegate(cont)
|
||||
delegate = d
|
||||
output.capturePhoto(with: settings, delegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
@@ -123,19 +135,39 @@ actor CameraCaptureService {
|
||||
try await self.ensureAccess(for: .audio)
|
||||
}
|
||||
|
||||
let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
|
||||
preferFrontCamera: facing == .front,
|
||||
deviceId: deviceId,
|
||||
includeAudio: includeAudio,
|
||||
durationMs: durationMs,
|
||||
pickCamera: { preferFrontCamera, deviceId in
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: Self.mapMovieSetupError)
|
||||
let session = prepared.session
|
||||
let output = prepared.output
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = .high
|
||||
|
||||
guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
|
||||
throw CameraError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
guard session.canAddInput(cameraInput) else {
|
||||
throw CameraError.captureFailed("Failed to add camera input")
|
||||
}
|
||||
session.addInput(cameraInput)
|
||||
|
||||
if includeAudio {
|
||||
guard let mic = AVCaptureDevice.default(for: .audio) else {
|
||||
throw CameraError.microphoneUnavailable
|
||||
}
|
||||
let micInput = try AVCaptureDeviceInput(device: mic)
|
||||
guard session.canAddInput(micInput) else {
|
||||
throw CameraError.captureFailed("Failed to add microphone input")
|
||||
}
|
||||
session.addInput(micInput)
|
||||
}
|
||||
|
||||
let output = AVCaptureMovieFileOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
throw CameraError.captureFailed("Failed to add movie output")
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
|
||||
|
||||
session.startRunning()
|
||||
defer { session.stopRunning() }
|
||||
await Self.warmUpCaptureSession()
|
||||
|
||||
let tmpMovURL = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
|
||||
@@ -148,6 +180,7 @@ actor CameraCaptureService {
|
||||
return FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
|
||||
}()
|
||||
|
||||
// Ensure we don't fail exporting due to an existing file.
|
||||
try? FileManager().removeItem(at: outputURL)
|
||||
|
||||
@@ -159,12 +192,28 @@ actor CameraCaptureService {
|
||||
output.startRecording(to: tmpMovURL, recordingDelegate: d)
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
|
||||
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: mediaType)
|
||||
switch status {
|
||||
case .authorized:
|
||||
return
|
||||
case .notDetermined:
|
||||
let ok = await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVCaptureDevice.requestAccess(for: mediaType) { granted in
|
||||
cont.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
@unknown default:
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
}
|
||||
@@ -229,13 +278,6 @@ actor CameraCaptureService {
|
||||
return min(60000, max(250, v))
|
||||
}
|
||||
|
||||
private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
|
||||
CameraCapturePipelineSupport.mapMovieSetupError(
|
||||
setupError,
|
||||
microphoneUnavailableError: .microphoneUnavailable,
|
||||
captureFailed: { .captureFailed($0) })
|
||||
}
|
||||
|
||||
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
|
||||
let asset = AVURLAsset(url: inputURL)
|
||||
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
|
||||
@@ -273,6 +315,11 @@ actor CameraCaptureService {
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func warmUpCaptureSession() async {
|
||||
// A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
|
||||
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
|
||||
}
|
||||
|
||||
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
|
||||
let stepNs: UInt64 = 50_000_000
|
||||
let maxSteps = 30 // ~1.5s
|
||||
@@ -291,7 +338,11 @@ actor CameraCaptureService {
|
||||
}
|
||||
|
||||
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
|
||||
CameraCapturePipelineSupport.positionLabel(position)
|
||||
switch position {
|
||||
case .front: "front"
|
||||
case .back: "back"
|
||||
default: "unspecified"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,40 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
}
|
||||
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
}
|
||||
guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" { return true }
|
||||
if host.hasSuffix(".local") { return true }
|
||||
if host.hasSuffix(".ts.net") { return true }
|
||||
if host.hasSuffix(".tailscale.net") { return true }
|
||||
if !host.contains("."), !host.contains(":") { return true }
|
||||
if let ipv4 = Self.parseIPv4(host) {
|
||||
return Self.isLocalNetworkIPv4(ipv4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
let (a, b, _, _) = ip
|
||||
if a == 10 { return true }
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
if a == 192, b == 168 { return true }
|
||||
if a == 127 { return true }
|
||||
if a == 169, b == 254 { return true }
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
let watcher: SimpleFileWatcher
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [url.path],
|
||||
queueLabel: "ai.openclaw.canvaswatcher",
|
||||
onChange: onChange))
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ extension CanvasWindowController {
|
||||
}
|
||||
|
||||
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
LoopbackHost.parseIPv4(host)
|
||||
CanvasA2UIActionMessageHandler.parseIPv4(host)
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
LoopbackHost.isLocalNetworkIPv4(ip)
|
||||
CanvasA2UIActionMessageHandler.isLocalNetworkIPv4(ip)
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
|
||||
@@ -274,11 +274,25 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
func applyDebugStatusIfNeeded() {
|
||||
WebViewJavaScriptSupport.applyDebugStatus(
|
||||
webView: self.webView,
|
||||
enabled: self.debugStatusEnabled,
|
||||
title: self.debugStatusTitle,
|
||||
subtitle: self.debugStatusSubtitle)
|
||||
let enabled = self.debugStatusEnabled
|
||||
let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
|
||||
let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
|
||||
let js = """
|
||||
(() => {
|
||||
try {
|
||||
const api = globalThis.__openclaw;
|
||||
if (!api) return;
|
||||
if (typeof api.setDebugStatusEnabled === 'function') {
|
||||
api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
|
||||
}
|
||||
if (!\(enabled ? "true" : "false")) return;
|
||||
if (typeof api.setStatus === 'function') {
|
||||
api.setStatus(\(title), \(subtitle));
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
"""
|
||||
self.webView.evaluateJavaScript(js) { _, _ in }
|
||||
}
|
||||
|
||||
private func loadFile(_ url: URL) {
|
||||
@@ -288,7 +302,19 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
func eval(javaScript: String) async throws -> String {
|
||||
try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript)
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.webView.evaluateJavaScript(javaScript) { result, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if let result {
|
||||
cont.resume(returning: String(describing: result))
|
||||
} else {
|
||||
cont.resume(returning: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot(to outPath: String?) async throws -> String {
|
||||
|
||||
@@ -9,90 +9,6 @@ extension ChannelsSettings {
|
||||
self.store.snapshot?.decodeChannel(id, as: type)
|
||||
}
|
||||
|
||||
private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
|
||||
if !configured { return .secondary }
|
||||
if hasError { return .orange }
|
||||
if probeOk == false { return .orange }
|
||||
if running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
private func configuredChannelSummary(configured: Bool, running: Bool) -> String {
|
||||
if !configured { return "Not configured" }
|
||||
if running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
private func appendProbeDetails(
|
||||
lines: inout [String],
|
||||
probeOk: Bool?,
|
||||
probeStatus: Int?,
|
||||
probeElapsedMs: Double?,
|
||||
probeVersion: String? = nil,
|
||||
probeError: String? = nil,
|
||||
lastProbeAtMs: Double?,
|
||||
lastError: String?)
|
||||
{
|
||||
if let probeOk {
|
||||
if probeOk {
|
||||
if let version = probeVersion, !version.isEmpty {
|
||||
lines.append("Version \(version)")
|
||||
}
|
||||
if let elapsed = probeElapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else if let probeError, !probeError.isEmpty {
|
||||
lines.append("Probe error: \(probeError)")
|
||||
} else {
|
||||
let code = probeStatus.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: lastProbeAtMs) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let lastError, !lastError.isEmpty {
|
||||
lines.append("Error: \(lastError)")
|
||||
}
|
||||
}
|
||||
|
||||
private func finishDetails(
|
||||
lines: inout [String],
|
||||
probeOk: Bool?,
|
||||
probeStatus: Int?,
|
||||
probeElapsedMs: Double?,
|
||||
probeVersion: String? = nil,
|
||||
probeError: String? = nil,
|
||||
lastProbeAtMs: Double?,
|
||||
lastError: String?) -> String?
|
||||
{
|
||||
self.appendProbeDetails(
|
||||
lines: &lines,
|
||||
probeOk: probeOk,
|
||||
probeStatus: probeStatus,
|
||||
probeElapsedMs: probeElapsedMs,
|
||||
probeVersion: probeVersion,
|
||||
probeError: probeError,
|
||||
lastProbeAtMs: lastProbeAtMs,
|
||||
lastError: lastError)
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func finishProbeDetails(
|
||||
lines: inout [String],
|
||||
probe: (ok: Bool?, status: Int?, elapsedMs: Double?),
|
||||
lastProbeAtMs: Double?,
|
||||
lastError: String?) -> String?
|
||||
{
|
||||
self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: probe.ok,
|
||||
probeStatus: probe.status,
|
||||
probeElapsedMs: probe.elapsedMs,
|
||||
lastProbeAtMs: lastProbeAtMs,
|
||||
lastError: lastError)
|
||||
}
|
||||
|
||||
var whatsAppTint: Color {
|
||||
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
|
||||
else { return .secondary }
|
||||
@@ -107,51 +23,51 @@ extension ChannelsSettings {
|
||||
var telegramTint: Color {
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return .secondary }
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
var discordTint: Color {
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return .secondary }
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
var googlechatTint: Color {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return .secondary }
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
var signalTint: Color {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return .secondary }
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
var imessageTint: Color {
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return .secondary }
|
||||
return self.configuredChannelTint(
|
||||
configured: status.configured,
|
||||
running: status.running,
|
||||
hasError: status.lastError != nil,
|
||||
probeOk: status.probe?.ok)
|
||||
if !status.configured { return .secondary }
|
||||
if status.lastError != nil { return .orange }
|
||||
if status.probe?.ok == false { return .orange }
|
||||
if status.running { return .green }
|
||||
return .orange
|
||||
}
|
||||
|
||||
var whatsAppSummary: String {
|
||||
@@ -166,31 +82,41 @@ extension ChannelsSettings {
|
||||
var telegramSummary: String {
|
||||
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
|
||||
else { return "Checking…" }
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var discordSummary: String {
|
||||
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
|
||||
else { return "Checking…" }
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var googlechatSummary: String {
|
||||
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
|
||||
else { return "Checking…" }
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var signalSummary: String {
|
||||
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
|
||||
else { return "Checking…" }
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var imessageSummary: String {
|
||||
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
|
||||
else { return "Checking…" }
|
||||
return self.configuredChannelSummary(configured: status.configured, running: status.running)
|
||||
if !status.configured { return "Not configured" }
|
||||
if status.running { return "Running" }
|
||||
return "Configured"
|
||||
}
|
||||
|
||||
var whatsAppDetails: String? {
|
||||
@@ -242,15 +168,18 @@ extension ChannelsSettings {
|
||||
if let url = probe.webhook?.url, !url.isEmpty {
|
||||
lines.append("Webhook: \(url)")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
return self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: status.probe?.ok,
|
||||
probeStatus: status.probe?.status,
|
||||
probeElapsedMs: nil,
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var discordDetails: String? {
|
||||
@@ -260,17 +189,26 @@ extension ChannelsSettings {
|
||||
if let source = status.tokenSource {
|
||||
lines.append("Token source: \(source)")
|
||||
}
|
||||
if let name = status.probe?.bot?.username, !name.isEmpty {
|
||||
lines.append("Bot: @\(name)")
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let name = probe.bot?.username {
|
||||
lines.append("Bot: @\(name)")
|
||||
}
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
return self.finishProbeDetails(
|
||||
lines: &lines,
|
||||
probe: (
|
||||
ok: status.probe?.ok,
|
||||
status: status.probe?.status,
|
||||
elapsedMs: status.probe?.elapsedMs),
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var googlechatDetails: String? {
|
||||
@@ -285,14 +223,23 @@ extension ChannelsSettings {
|
||||
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
|
||||
lines.append("Audience: \(label)")
|
||||
}
|
||||
return self.finishProbeDetails(
|
||||
lines: &lines,
|
||||
probe: (
|
||||
ok: status.probe?.ok,
|
||||
status: status.probe?.status,
|
||||
elapsedMs: status.probe?.elapsedMs),
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var signalDetails: String? {
|
||||
@@ -300,14 +247,26 @@ extension ChannelsSettings {
|
||||
else { return nil }
|
||||
var lines: [String] = []
|
||||
lines.append("Base URL: \(status.baseUrl)")
|
||||
return self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: status.probe?.ok,
|
||||
probeStatus: status.probe?.status,
|
||||
probeElapsedMs: status.probe?.elapsedMs,
|
||||
probeVersion: status.probe?.version,
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
if let probe = status.probe {
|
||||
if probe.ok {
|
||||
if let version = probe.version, !version.isEmpty {
|
||||
lines.append("Version \(version)")
|
||||
}
|
||||
if let elapsed = probe.elapsedMs {
|
||||
lines.append("Probe \(Int(elapsed))ms")
|
||||
}
|
||||
} else {
|
||||
let code = probe.status.map { String($0) } ?? "unknown"
|
||||
lines.append("Probe failed (\(code))")
|
||||
}
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var imessageDetails: String? {
|
||||
@@ -320,14 +279,17 @@ extension ChannelsSettings {
|
||||
if let dbPath = status.dbPath, !dbPath.isEmpty {
|
||||
lines.append("DB: \(dbPath)")
|
||||
}
|
||||
return self.finishDetails(
|
||||
lines: &lines,
|
||||
probeOk: status.probe?.ok,
|
||||
probeStatus: nil,
|
||||
probeElapsedMs: nil,
|
||||
probeError: status.probe?.error,
|
||||
lastProbeAtMs: status.lastProbeAt,
|
||||
lastError: status.lastError)
|
||||
if let probe = status.probe, !probe.ok {
|
||||
let err = probe.error ?? "probe failed"
|
||||
lines.append("Probe error: \(err)")
|
||||
}
|
||||
if let last = self.date(fromMs: status.lastProbeAt) {
|
||||
lines.append("Last probe \(relativeAge(from: last))")
|
||||
}
|
||||
if let err = status.lastError, !err.isEmpty {
|
||||
lines.append("Error: \(err)")
|
||||
}
|
||||
return lines.isEmpty ? nil : lines.joined(separator: " · ")
|
||||
}
|
||||
|
||||
var orderedChannels: [ChannelItem] {
|
||||
|
||||
@@ -18,7 +18,7 @@ extension ChannelsSettings {
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
SettingsSidebarScroll {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if !self.enabledChannels.isEmpty {
|
||||
self.sidebarSectionHeader("Configured")
|
||||
@@ -34,7 +34,14 @@ extension ChannelsSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ColorHexSupport {
|
||||
static func color(fromHex raw: String?) -> Color? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
|
||||
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
|
||||
let r = Double((value >> 16) & 0xFF) / 255.0
|
||||
let g = Double((value >> 8) & 0xFF) / 255.0
|
||||
let b = Double(value & 0xFF) / 255.0
|
||||
return Color(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
let watcher: SimpleFileWatcher
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
@@ -15,7 +15,7 @@ final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
let watchedDirPath = self.watchedDir.path
|
||||
let targetPath = self.targetPath
|
||||
let targetName = self.targetName
|
||||
self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [watchedDirPath],
|
||||
queueLabel: "ai.openclaw.configwatcher",
|
||||
shouldNotify: { _, eventPaths in
|
||||
@@ -28,7 +28,18 @@ final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
}
|
||||
return false
|
||||
},
|
||||
onChange: onChange))
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ extension ConfigSettings {
|
||||
}
|
||||
|
||||
private var sidebar: some View {
|
||||
SettingsSidebarScroll {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 8) {
|
||||
if self.sections.isEmpty {
|
||||
Text("No config sections available.")
|
||||
@@ -86,7 +86,14 @@ extension ConfigSettings {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
.frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(nsColor: .windowBackgroundColor)))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var detail: some View {
|
||||
|
||||
@@ -6,6 +6,10 @@ struct ContextMenuCardView: View {
|
||||
private let rows: [SessionRow]
|
||||
private let statusText: String?
|
||||
private let isLoading: Bool
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 8
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
private let barHeight: CGFloat = 3
|
||||
|
||||
init(
|
||||
@@ -19,32 +23,45 @@ struct ContextMenuCardView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MenuHeaderCard(
|
||||
title: "Context",
|
||||
subtitle: self.subtitle,
|
||||
statusText: self.statusText,
|
||||
paddingBottom: 8)
|
||||
{
|
||||
if self.statusText == nil {
|
||||
if self.rows.isEmpty, !self.isLoading {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.rows.isEmpty, self.isLoading {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
self.placeholderRow
|
||||
}
|
||||
} else {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if self.rows.isEmpty, !self.isLoading {
|
||||
Text("No active sessions")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.rows.isEmpty, self.isLoading {
|
||||
ForEach(0..<2, id: \.self) { _ in
|
||||
self.placeholderRow
|
||||
}
|
||||
} else {
|
||||
ForEach(self.rows) { row in
|
||||
self.sessionRow(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
|
||||
@@ -336,8 +336,16 @@ final class ControlChannel {
|
||||
}
|
||||
|
||||
private func startEventStream() {
|
||||
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||
self?.handle(push: push)
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,14 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
DurationFormattingSupport.conciseDuration(ms: ms)
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ final class CronJobsStore {
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.eventTask == nil else { return }
|
||||
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
self.startGatewaySubscription()
|
||||
self.pollTask = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refreshJobs()
|
||||
@@ -144,6 +142,20 @@ final class CronJobsStore {
|
||||
|
||||
// MARK: - Gateway events
|
||||
|
||||
private func startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "cron":
|
||||
|
||||
@@ -31,7 +31,15 @@ extension CronSettings {
|
||||
}
|
||||
|
||||
func formatDuration(ms: Int) -> String {
|
||||
DurationFormattingSupport.conciseDuration(ms: ms)
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
|
||||
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
|
||||
|
||||
@@ -17,7 +17,9 @@ final class DevicePairingApprovalPrompter {
|
||||
private var queue: [PendingRequest] = []
|
||||
var pendingCount: Int = 0
|
||||
var pendingRepairCount: Int = 0
|
||||
private let alertState = PairingAlertState()
|
||||
private var activeAlert: NSAlert?
|
||||
private var activeRequestId: String?
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var resolvedByRequestId: Set<String> = []
|
||||
|
||||
private struct PairingList: Codable {
|
||||
@@ -53,35 +55,48 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||
|
||||
func start() {
|
||||
self.startPushTask()
|
||||
private struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let deviceId: String
|
||||
let decision: String
|
||||
let ts: Double
|
||||
}
|
||||
|
||||
private func startPushTask() {
|
||||
PairingAlertSupport.startPairingPushTask(
|
||||
task: &self.task,
|
||||
isStopping: &self.isStopping,
|
||||
loadPending: self.loadPendingRequestsFromGateway,
|
||||
handlePush: self.handle(push:))
|
||||
private enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.isStopping = false
|
||||
self.task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
await self.loadPendingRequestsFromGateway()
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.stopPushTask()
|
||||
self.isStopping = true
|
||||
self.endActiveAlert()
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.queue.removeAll(keepingCapacity: false)
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.activeRequestId = nil
|
||||
self.alertHostWindow?.orderOut(nil)
|
||||
self.alertHostWindow?.close()
|
||||
self.alertHostWindow = nil
|
||||
self.resolvedByRequestId.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func stopPushTask() {
|
||||
PairingAlertSupport.stopPairingPrompter(
|
||||
isStopping: &self.isStopping,
|
||||
task: &self.task,
|
||||
queue: &self.queue,
|
||||
isPresenting: &self.isPresenting,
|
||||
state: self.alertState)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
do {
|
||||
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
|
||||
@@ -112,13 +127,44 @@ final class DevicePairingApprovalPrompter {
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow device to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow device to connect?"
|
||||
alert.informativeText = Self.describe(req)
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
self.activeAlert = alert
|
||||
self.activeRequestId = req.requestId
|
||||
let hostWindow = self.requireAlertHostWindow()
|
||||
|
||||
let sheetSize = alert.window.frame.size
|
||||
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||
let bounds = screen.visibleFrame
|
||||
let x = bounds.midX - (sheetSize.width / 2)
|
||||
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||
} else {
|
||||
hostWindow.center()
|
||||
}
|
||||
|
||||
hostWindow.makeKeyAndOrderFront(nil)
|
||||
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeRequestId = nil
|
||||
self.activeAlert = nil
|
||||
await self.handleAlertResponse(response, request: req)
|
||||
hostWindow.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
@@ -160,27 +206,33 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
await PairingAlertSupport.approveRequest(
|
||||
requestId: requestId,
|
||||
kind: "device",
|
||||
logger: self.logger)
|
||||
{
|
||||
do {
|
||||
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
|
||||
self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
await PairingAlertSupport.rejectRequest(
|
||||
requestId: requestId,
|
||||
kind: "device",
|
||||
logger: self.logger)
|
||||
{
|
||||
do {
|
||||
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
|
||||
self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(state: self.alertState)
|
||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
@@ -217,10 +269,9 @@ final class DevicePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func handleResolved(_ resolved: PairingResolvedEvent) {
|
||||
let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
|
||||
? PairingAlertSupport.PairingResolution.approved
|
||||
: PairingAlertSupport.PairingResolution.rejected
|
||||
if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
|
||||
let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution
|
||||
.approved : .rejected
|
||||
if let activeRequestId, activeRequestId == resolved.requestId {
|
||||
self.resolvedByRequestId.insert(resolved.requestId)
|
||||
self.endActiveAlert()
|
||||
let decision = resolution.rawValue
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum DurationFormattingSupport {
|
||||
static func conciseDuration(ms: Int) -> String {
|
||||
if ms < 1000 { return "\(ms)ms" }
|
||||
let s = Double(ms) / 1000.0
|
||||
if s < 60 { return "\(Int(round(s)))s" }
|
||||
let m = s / 60.0
|
||||
if m < 60 { return "\(Int(round(m)))m" }
|
||||
let h = m / 60.0
|
||||
if h < 48 { return "\(Int(round(h)))h" }
|
||||
let d = h / 24.0
|
||||
return "\(Int(round(d)))d"
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,15 @@ final class ExecApprovalsGatewayPrompter {
|
||||
}
|
||||
|
||||
func start() {
|
||||
SimpleTaskSupport.start(task: &self.task) { [weak self] in
|
||||
guard self.task == nil else { return }
|
||||
self.task = Task { [weak self] in
|
||||
await self?.run()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
SimpleTaskSupport.stop(task: &self.task)
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
private func run() async {
|
||||
|
||||
@@ -73,22 +73,6 @@ private struct ExecHostResponse: Codable {
|
||||
var error: ExecHostError?
|
||||
}
|
||||
|
||||
private func readLineFromHandle(_ handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketClient {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
@@ -175,12 +159,28 @@ enum ExecApprovalsSocketClient {
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
|
||||
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
|
||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||
let lineData = line.data(using: .utf8)
|
||||
else { return nil }
|
||||
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||
return response.decision
|
||||
}
|
||||
|
||||
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -781,7 +781,7 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||
return
|
||||
}
|
||||
guard let line = try readLineFromHandle(handle, maxBytes: 256_000),
|
||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||
let data = line.data(using: .utf8)
|
||||
else {
|
||||
return
|
||||
@@ -815,6 +815,22 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func sendApprovalResponse(
|
||||
handle: FileHandle,
|
||||
id: String,
|
||||
|
||||
@@ -12,6 +12,19 @@ enum ExecCommandToken {
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
private static let optionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let flagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
@@ -42,11 +55,11 @@ enum ExecEnvInvocationUnwrapper {
|
||||
if token.hasPrefix("-"), token != "-" {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
if self.flagOptions.contains(flag) {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
if self.optionsWithValue.contains(flag) {
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum ExecEnvOptions {
|
||||
static let withValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
|
||||
static let flagOnly = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
|
||||
static let inlineValuePrefixes = [
|
||||
"-u",
|
||||
"-c",
|
||||
"-s",
|
||||
"--unset=",
|
||||
"--chdir=",
|
||||
"--split-string=",
|
||||
"--default-signal=",
|
||||
"--ignore-signal=",
|
||||
"--block-signal=",
|
||||
]
|
||||
}
|
||||
@@ -39,6 +39,30 @@ enum ExecSystemRunCommandValidator {
|
||||
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
|
||||
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"])
|
||||
|
||||
private static let envOptionsWithValue = Set([
|
||||
"-u",
|
||||
"--unset",
|
||||
"-c",
|
||||
"--chdir",
|
||||
"-s",
|
||||
"--split-string",
|
||||
"--default-signal",
|
||||
"--ignore-signal",
|
||||
"--block-signal",
|
||||
])
|
||||
private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
|
||||
private static let envInlineValuePrefixes = [
|
||||
"-u",
|
||||
"-c",
|
||||
"-s",
|
||||
"--unset=",
|
||||
"--chdir=",
|
||||
"--split-string=",
|
||||
"--default-signal=",
|
||||
"--ignore-signal=",
|
||||
"--block-signal=",
|
||||
]
|
||||
|
||||
private struct EnvUnwrapResult {
|
||||
let argv: [String]
|
||||
let usesModifiers: Bool
|
||||
@@ -89,7 +113,7 @@ enum ExecSystemRunCommandValidator {
|
||||
}
|
||||
|
||||
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
|
||||
ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
|
||||
self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
|
||||
}
|
||||
|
||||
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
|
||||
@@ -124,12 +148,12 @@ enum ExecSystemRunCommandValidator {
|
||||
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
if self.envFlagOptions.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
if self.envOptionsWithValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
@@ -277,15 +301,10 @@ enum ExecSystemRunCommandValidator {
|
||||
return current
|
||||
}
|
||||
|
||||
private struct InlineCommandTokenMatch {
|
||||
var tokenIndex: Int
|
||||
var inlineCommand: String?
|
||||
}
|
||||
|
||||
private static func findInlineCommandTokenMatch(
|
||||
private static func resolveInlineCommandTokenIndex(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> InlineCommandTokenMatch?
|
||||
allowCombinedC: Bool) -> Int?
|
||||
{
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
@@ -299,35 +318,21 @@ enum ExecSystemRunCommandValidator {
|
||||
break
|
||||
}
|
||||
if flags.contains(lower) {
|
||||
return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil)
|
||||
return idx + 1 < argv.count ? idx + 1 : nil
|
||||
}
|
||||
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||
let inline = String(token.dropFirst(inlineOffset))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return InlineCommandTokenMatch(
|
||||
tokenIndex: idx,
|
||||
inlineCommand: inline.isEmpty ? nil : inline)
|
||||
if !inline.isEmpty {
|
||||
return idx
|
||||
}
|
||||
return idx + 1 < argv.count ? idx + 1 : nil
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveInlineCommandTokenIndex(
|
||||
_ argv: [String],
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> Int?
|
||||
{
|
||||
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
}
|
||||
if match.inlineCommand != nil {
|
||||
return match.tokenIndex
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return nextIndex < argv.count ? nextIndex : nil
|
||||
}
|
||||
|
||||
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
|
||||
let chars = Array(token.lowercased())
|
||||
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
|
||||
@@ -366,14 +371,30 @@ enum ExecSystemRunCommandValidator {
|
||||
flags: Set<String>,
|
||||
allowCombinedC: Bool) -> String?
|
||||
{
|
||||
guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
|
||||
return nil
|
||||
var idx = 1
|
||||
while idx < argv.count {
|
||||
let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
let lower = token.lowercased()
|
||||
if lower == "--" {
|
||||
break
|
||||
}
|
||||
if flags.contains(lower) {
|
||||
return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
|
||||
}
|
||||
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
|
||||
let inline = String(token.dropFirst(inlineOffset))
|
||||
if let inlineValue = self.trimmedNonEmpty(inline) {
|
||||
return inlineValue
|
||||
}
|
||||
return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
if let inlineCommand = match.inlineCommand {
|
||||
return inlineCommand
|
||||
}
|
||||
let nextIndex = match.tokenIndex + 1
|
||||
return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
|
||||
|
||||
@@ -48,11 +48,27 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
SelectionStateIndicator(selected: selected)
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
} else {
|
||||
Image(systemName: "arrow.right.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.openClawSelectableRowChrome(
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowBackground(
|
||||
selected: selected,
|
||||
hovered: self.hoveredGatewayID == gateway.id)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
selected ? Color.accentColor.opacity(0.45) : Color.clear,
|
||||
lineWidth: 1))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -90,6 +106,12 @@ struct GatewayDiscoveryInlineList: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
if selected { return Color.accentColor.opacity(0.12) }
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String?) -> String {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import OpenClawDiscovery
|
||||
|
||||
@MainActor
|
||||
enum GatewayDiscoverySelectionSupport {
|
||||
static func applyRemoteSelection(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
state: AppState)
|
||||
{
|
||||
if state.remoteTransport == .direct {
|
||||
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,8 +347,21 @@ actor GatewayEndpointStore {
|
||||
|
||||
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
|
||||
func ensureRemoteControlTunnel() async throws -> UInt16 {
|
||||
try await self.requireRemoteMode()
|
||||
if let url = try self.resolveDirectRemoteURL() {
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||
let portInt = UInt16(exactly: port)
|
||||
else {
|
||||
@@ -412,9 +425,22 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
|
||||
try await self.requireRemoteMode()
|
||||
let mode = await self.deps.mode()
|
||||
guard mode == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
if let url = try self.resolveDirectRemoteURL() {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
self.cancelRemoteEnsure()
|
||||
@@ -465,27 +491,6 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
}
|
||||
|
||||
private func requireRemoteMode() async throws {
|
||||
guard await self.deps.mode() == .remote else {
|
||||
throw NSError(
|
||||
domain: "RemoteTunnel",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDirectRemoteURL() throws -> URL? {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func removeSubscriber(_ id: UUID) {
|
||||
self.subscribers[id] = nil
|
||||
}
|
||||
|
||||
@@ -180,11 +180,25 @@ extension GatewayLaunchAgentManager {
|
||||
}
|
||||
|
||||
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
|
||||
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
|
||||
return ParsedDaemonJson(text: parsed.text, object: parsed.object)
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return ParsedDaemonJson(text: jsonText, object: object)
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
TextSummarySupport.summarizeLastLine(text)
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayPushSubscription {
|
||||
@MainActor
|
||||
static func consume(
|
||||
bufferingNewest: Int? = nil,
|
||||
onPush: @escaping @MainActor (GatewayPush) -> Void) async
|
||||
{
|
||||
let stream: AsyncStream<GatewayPush> = if let bufferingNewest {
|
||||
await GatewayConnection.shared.subscribe(bufferingNewest: bufferingNewest)
|
||||
} else {
|
||||
await GatewayConnection.shared.subscribe()
|
||||
}
|
||||
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run {
|
||||
onPush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func restartTask(
|
||||
task: inout Task<Void, Never>?,
|
||||
bufferingNewest: Int? = nil,
|
||||
onPush: @escaping @MainActor (GatewayPush) -> Void)
|
||||
{
|
||||
task?.cancel()
|
||||
task = Task {
|
||||
await self.consume(bufferingNewest: bufferingNewest, onPush: onPush)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,41 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Network
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
private static func isLoopbackHost(_ rawHost: String) -> Bool {
|
||||
var host = rawHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
|
||||
if host.hasSuffix(".") {
|
||||
host.removeLast()
|
||||
}
|
||||
if let zoneIndex = host.firstIndex(of: "%") {
|
||||
host = String(host[..<zoneIndex])
|
||||
}
|
||||
if host.isEmpty {
|
||||
return false
|
||||
}
|
||||
if host == "localhost" || host == "0.0.0.0" || host == "::" {
|
||||
return true
|
||||
}
|
||||
|
||||
if let ipv4 = IPv4Address(host) {
|
||||
return ipv4.rawValue.first == 127
|
||||
}
|
||||
if let ipv6 = IPv6Address(host) {
|
||||
let bytes = Array(ipv6.rawValue)
|
||||
let isV6Loopback = bytes[0..<15].allSatisfy { $0 == 0 } && bytes[15] == 1
|
||||
if isV6Loopback {
|
||||
return true
|
||||
}
|
||||
let isMappedV4 = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
|
||||
return isMappedV4 && bytes[12] == 127
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
@@ -40,7 +74,7 @@ enum GatewayRemoteConfig {
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
if scheme == "ws", !LoopbackHost.isLoopbackHost(host) {
|
||||
if scheme == "ws", !self.isLoopbackHost(host) {
|
||||
return nil
|
||||
}
|
||||
if scheme == "ws", url.port == nil {
|
||||
|
||||
@@ -260,7 +260,17 @@ struct GeneralSettings: View {
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
self.remoteTestButton(disabled: !canTest)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || !canTest)
|
||||
}
|
||||
if let validationMessage {
|
||||
Text(validationMessage)
|
||||
@@ -280,8 +290,18 @@ struct GeneralSettings: View {
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
self.remoteTestButton(
|
||||
disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text(
|
||||
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
|
||||
@@ -291,20 +311,6 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteTestButton(disabled: Bool) -> some View {
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || disabled)
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
@@ -666,7 +672,19 @@ extension GeneralSettings {
|
||||
|
||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
|
||||
|
||||
if self.state.remoteTransport == .direct {
|
||||
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,8 +100,17 @@ final class HoverHUDController {
|
||||
return
|
||||
}
|
||||
|
||||
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) {
|
||||
self.model.isVisible = false
|
||||
let target = window.frame.offsetBy(dx: 0, dy: 6)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.14
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
window.orderOut(nil)
|
||||
self.model.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +140,15 @@ final class HoverHUDController {
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
let start = target.offsetBy(dx: 0, dy: 8)
|
||||
OverlayPanelFactory.animatePresent(window: window, from: start, to: target)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
window.orderFrontRegardless()
|
||||
self.updateWindowFrame(animate: true)
|
||||
@@ -140,10 +157,22 @@ final class HoverHUDController {
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let panel = OverlayPanelFactory.makePanel(
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
|
||||
level: .statusBar,
|
||||
hasShadow: true)
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.level = .statusBar
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
|
||||
let host = NSHostingView(rootView: HoverHUDView(controller: self))
|
||||
host.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -172,7 +201,17 @@ final class HoverHUDController {
|
||||
}
|
||||
|
||||
private func updateWindowFrame(animate: Bool = false) {
|
||||
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
|
||||
guard let window else { return }
|
||||
let frame = self.targetFrame()
|
||||
if animate {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func installDismissMonitor() {
|
||||
@@ -192,7 +231,10 @@ final class HoverHUDController {
|
||||
}
|
||||
|
||||
private func removeDismissMonitor() {
|
||||
OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
|
||||
if let monitor = self.dismissMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
self.dismissMonitor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,16 @@ struct InstancesSettings: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
SettingsRefreshButton(isLoading: self.store.isLoading) {
|
||||
Task { await self.store.refresh() }
|
||||
if self.store.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button {
|
||||
Task { await self.store.refresh() }
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("Refresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -268,7 +276,7 @@ struct InstancesSettings: View {
|
||||
}
|
||||
|
||||
private func platformIcon(_ raw: String) -> String {
|
||||
let (prefix, _) = PlatformLabelFormatter.parse(raw)
|
||||
let (prefix, _) = self.parsePlatform(raw)
|
||||
switch prefix {
|
||||
case "macos":
|
||||
return "laptopcomputer"
|
||||
@@ -286,7 +294,31 @@ struct InstancesSettings: View {
|
||||
}
|
||||
|
||||
private func prettyPlatform(_ raw: String) -> String? {
|
||||
PlatformLabelFormatter.pretty(raw)
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
let name: String = switch prefix {
|
||||
case "macos": "macOS"
|
||||
case "ios": "iOS"
|
||||
case "ipados": "iPadOS"
|
||||
case "tvos": "tvOS"
|
||||
case "watchos": "watchOS"
|
||||
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||
}
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
let parts = version.split(separator: ".").map(String.init)
|
||||
if parts.count >= 2 {
|
||||
return "\(name) \(parts[0]).\(parts[1])"
|
||||
}
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
|
||||
private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return ("", nil) }
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
let prefix = parts.first?.lowercased() ?? ""
|
||||
let versionToken = parts.dropFirst().first
|
||||
return (prefix, versionToken)
|
||||
}
|
||||
|
||||
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
|
||||
@@ -418,8 +450,8 @@ extension InstancesSettings {
|
||||
_ = view.prettyPlatform("ipados 17.1")
|
||||
_ = view.prettyPlatform("linux")
|
||||
_ = view.prettyPlatform(" ")
|
||||
_ = PlatformLabelFormatter.parse("macOS 14.1")
|
||||
_ = PlatformLabelFormatter.parse(" ")
|
||||
_ = view.parsePlatform("macOS 14.1")
|
||||
_ = view.parsePlatform(" ")
|
||||
_ = view.presenceUpdateSourceShortText("self")
|
||||
_ = view.presenceUpdateSourceShortText("instances-refresh")
|
||||
_ = view.presenceUpdateSourceShortText("seq gap")
|
||||
|
||||
@@ -62,11 +62,14 @@ final class InstancesStore {
|
||||
self.startCount += 1
|
||||
guard self.startCount == 1 else { return }
|
||||
guard self.task == nil else { return }
|
||||
GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
|
||||
await self?.refresh()
|
||||
self.startGatewaySubscription()
|
||||
self.task = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +84,20 @@ final class InstancesStore {
|
||||
self.eventTask = nil
|
||||
}
|
||||
|
||||
private func startGatewaySubscription() {
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handle(push: push)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
switch push {
|
||||
case let .event(evt) where evt.event == "presence":
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum JSONObjectExtractionSupport {
|
||||
static func extract(from raw: String) -> (text: String, object: [String: Any])? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
return (jsonText, object)
|
||||
}
|
||||
}
|
||||
@@ -98,42 +98,23 @@ extension Logger.Message.StringInterpolation {
|
||||
}
|
||||
}
|
||||
|
||||
private func stringifyLogMetadataValue(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { stringifyLogMetadataValue($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
struct OpenClawOSLogHandler: LogHandler {
|
||||
private let osLogger: os.Logger
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
private protocol AppLogLevelBackedHandler: LogHandler {
|
||||
var metadata: Logger.Metadata { get set }
|
||||
}
|
||||
|
||||
extension AppLogLevelBackedHandler {
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
|
||||
private let osLogger: os.Logger
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
init(subsystem: String, category: String) {
|
||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
@@ -176,16 +157,39 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
|
||||
guard !metadata.isEmpty else { return message.description }
|
||||
let meta = metadata
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }
|
||||
.map { "\($0.key)=\(self.stringify($0.value))" }
|
||||
.joined(separator: " ")
|
||||
return "\(message.description) [\(meta)]"
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
|
||||
struct OpenClawFileLogHandler: LogHandler {
|
||||
let label: String
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
var logLevel: Logger.Level {
|
||||
get { AppLogSettings.logLevel() }
|
||||
set { AppLogSettings.setLogLevel(newValue) }
|
||||
}
|
||||
|
||||
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
||||
get { self.metadata[key] }
|
||||
set { self.metadata[key] = newValue }
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
@@ -208,8 +212,21 @@ struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
|
||||
]
|
||||
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
|
||||
for (key, value) in merged {
|
||||
fields["meta.\(key)"] = stringifyLogMetadataValue(value)
|
||||
fields["meta.\(key)"] = Self.stringify(value)
|
||||
}
|
||||
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
|
||||
}
|
||||
|
||||
private static func stringify(_ value: Logger.Metadata.Value) -> String {
|
||||
switch value {
|
||||
case let .string(text):
|
||||
text
|
||||
case let .stringConvertible(value):
|
||||
String(describing: value)
|
||||
case let .array(values):
|
||||
"[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
|
||||
case let .dictionary(entries):
|
||||
"{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,17 @@ private final class StatusItemMouseHandlerView: NSView {
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
|
||||
@@ -170,11 +170,7 @@ struct MenuContent: View {
|
||||
await self.loadBrowserControlEnabled()
|
||||
}
|
||||
.onAppear {
|
||||
MicRefreshSupport.startObserver(self.micObserver) {
|
||||
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
self.startMicObserver()
|
||||
}
|
||||
.onDisappear {
|
||||
self.micRefreshTask?.cancel()
|
||||
@@ -429,7 +425,11 @@ struct MenuContent: View {
|
||||
}
|
||||
|
||||
private var voiceWakeBinding: Binding<Bool> {
|
||||
MicRefreshSupport.voiceWakeBinding(for: self.state)
|
||||
Binding(
|
||||
get: { self.state.swabbleEnabled },
|
||||
set: { newValue in
|
||||
Task { await self.state.setVoiceWakeEnabled(newValue) }
|
||||
})
|
||||
}
|
||||
|
||||
private var showVoiceWakeMicPicker: Bool {
|
||||
@@ -546,20 +546,46 @@ struct MenuContent: View {
|
||||
}
|
||||
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
|
||||
self.availableMics = self.filterAliveInputs(self.availableMics)
|
||||
self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
|
||||
selectedID: self.state.voiceWakeMicID,
|
||||
in: self.availableMics,
|
||||
uid: \.uid,
|
||||
name: \.name)
|
||||
self.updateSelectedMicName()
|
||||
self.loadingMics = false
|
||||
}
|
||||
|
||||
private func startMicObserver() {
|
||||
self.micObserver.start {
|
||||
Task { @MainActor in
|
||||
self.scheduleMicRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleMicRefresh() {
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
await self.loadMicrophones(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
|
||||
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
|
||||
guard !aliveUIDs.isEmpty else { return inputs }
|
||||
return inputs.filter { aliveUIDs.contains($0.uid) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateSelectedMicName() {
|
||||
let selected = self.state.voiceWakeMicID
|
||||
if selected.isEmpty {
|
||||
self.state.voiceWakeMicName = ""
|
||||
return
|
||||
}
|
||||
if let match = self.availableMics.first(where: { $0.uid == selected }) {
|
||||
self.state.voiceWakeMicName = match.name
|
||||
}
|
||||
}
|
||||
|
||||
private struct AudioInputDevice: Identifiable, Equatable {
|
||||
let uid: String
|
||||
let name: String
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuHeaderCard<Content: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let statusText: String?
|
||||
let paddingBottom: CGFloat
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
statusText: String? = nil,
|
||||
paddingBottom: CGFloat = 6,
|
||||
@ViewBuilder content: () -> Content = { EmptyView() })
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.statusText = statusText
|
||||
self.paddingBottom = paddingBottom
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
self.content
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, 20)
|
||||
.padding(.trailing, 10)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,17 @@ final class HighlightedMenuItemHostView: NSView {
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
|
||||
if let tracking {
|
||||
self.removeTrackingArea(tracking)
|
||||
}
|
||||
let options: NSTrackingArea.Options = [
|
||||
.mouseEnteredAndExited,
|
||||
.activeAlways,
|
||||
.inVisibleRect,
|
||||
]
|
||||
let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
|
||||
self.addTrackingArea(area)
|
||||
self.tracking = area
|
||||
}
|
||||
|
||||
override func mouseEntered(with event: NSEvent) {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum MenuItemHighlightColors {
|
||||
struct Palette {
|
||||
let primary: Color
|
||||
let secondary: Color
|
||||
}
|
||||
|
||||
static func primary(_ highlighted: Bool) -> Color {
|
||||
highlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
static func secondary(_ highlighted: Bool) -> Color {
|
||||
highlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
static func palette(_ highlighted: Bool) -> Palette {
|
||||
Palette(
|
||||
primary: self.primary(highlighted),
|
||||
secondary: self.secondary(highlighted))
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,37 @@ struct MenuSessionsHeaderView: View {
|
||||
let count: Int
|
||||
let statusText: String?
|
||||
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 6
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
MenuHeaderCard(
|
||||
title: "Context",
|
||||
subtitle: self.subtitle,
|
||||
statusText: self.statusText)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Context")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
|
||||
@@ -3,10 +3,29 @@ import SwiftUI
|
||||
struct MenuUsageHeaderView: View {
|
||||
let count: Int
|
||||
|
||||
private let paddingTop: CGFloat = 8
|
||||
private let paddingBottom: CGFloat = 6
|
||||
private let paddingTrailing: CGFloat = 10
|
||||
private let paddingLeading: CGFloat = 20
|
||||
|
||||
var body: some View {
|
||||
MenuHeaderCard(
|
||||
title: "Usage",
|
||||
subtitle: self.subtitle)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Usage")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 10)
|
||||
Text(self.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.top, self.paddingTop)
|
||||
.padding(.bottom, self.paddingBottom)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
.padding(.trailing, self.paddingTrailing)
|
||||
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
|
||||
.transaction { txn in txn.animation = nil }
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum MicRefreshSupport {
|
||||
private static let refreshDelayNs: UInt64 = 300_000_000
|
||||
|
||||
static func startObserver(_ observer: AudioInputDeviceObserver, triggerRefresh: @escaping @MainActor () -> Void) {
|
||||
observer.start {
|
||||
Task { @MainActor in
|
||||
triggerRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func schedule(
|
||||
refreshTask: inout Task<Void, Never>?,
|
||||
action: @escaping @MainActor () async -> Void)
|
||||
{
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: self.refreshDelayNs)
|
||||
guard !Task.isCancelled else { return }
|
||||
await action()
|
||||
}
|
||||
}
|
||||
|
||||
static func selectedMicName<T>(
|
||||
selectedID: String,
|
||||
in devices: [T],
|
||||
uid: KeyPath<T, String>,
|
||||
name: KeyPath<T, String>) -> String
|
||||
{
|
||||
guard !selectedID.isEmpty else { return "" }
|
||||
return devices.first(where: { $0[keyPath: uid] == selectedID })?[keyPath: name] ?? ""
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func voiceWakeBinding(for state: AppState) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { state.swabbleEnabled },
|
||||
set: { newValue in
|
||||
Task { await state.setVoiceWakeEnabled(newValue) }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
|
||||
enum Error: Swift.Error {
|
||||
case timeout
|
||||
case unavailable
|
||||
@@ -12,18 +12,21 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, Locatio
|
||||
private let manager = CLLocationManager()
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
|
||||
var locationManager: CLLocationManager {
|
||||
self.manager
|
||||
}
|
||||
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
|
||||
get { self.locationContinuation }
|
||||
set { self.locationContinuation = newValue }
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
self.configureLocationManager()
|
||||
self.manager.delegate = self
|
||||
self.manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func authorizationStatus() -> CLAuthorizationStatus {
|
||||
self.manager.authorizationStatus
|
||||
}
|
||||
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization {
|
||||
if #available(macOS 11.0, *) {
|
||||
return self.manager.accuracyAuthorization
|
||||
}
|
||||
return .fullAccuracy
|
||||
}
|
||||
|
||||
func currentLocation(
|
||||
@@ -34,15 +37,26 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, Locatio
|
||||
guard CLLocationManager.locationServicesEnabled() else {
|
||||
throw Error.unavailable
|
||||
}
|
||||
return try await LocationCurrentRequest.resolve(
|
||||
manager: self.manager,
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs,
|
||||
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
|
||||
try await self.withTimeout(timeoutMs: timeoutMs) {
|
||||
try await operation()
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
if let maxAgeMs,
|
||||
let cached = self.manager.location,
|
||||
now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
|
||||
{
|
||||
return cached
|
||||
}
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
let timeout = max(0, timeoutMs ?? 10000)
|
||||
return try await self.withTimeout(timeoutMs: timeout) {
|
||||
try await self.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestLocation() async throws -> CLLocation {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.locationContinuation = cont
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +103,17 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, Locatio
|
||||
}
|
||||
}
|
||||
|
||||
private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
|
||||
switch accuracy {
|
||||
case .coarse:
|
||||
kCLLocationAccuracyKilometer
|
||||
case .balanced:
|
||||
kCLLocationAccuracyHundredMeters
|
||||
case .precise:
|
||||
kCLLocationAccuracyBest
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
|
||||
@@ -32,7 +32,9 @@ final class NodePairingApprovalPrompter {
|
||||
private var queue: [PendingRequest] = []
|
||||
var pendingCount: Int = 0
|
||||
var pendingRepairCount: Int = 0
|
||||
private let alertState = PairingAlertState()
|
||||
private var activeAlert: NSAlert?
|
||||
private var activeRequestId: String?
|
||||
private var alertHostWindow: NSWindow?
|
||||
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
|
||||
private var autoApproveAttempts: Set<String> = []
|
||||
|
||||
@@ -66,43 +68,55 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
}
|
||||
|
||||
private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
|
||||
private typealias PairingResolution = PairingAlertSupport.PairingResolution
|
||||
|
||||
func start() {
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = nil
|
||||
self.startPushTask()
|
||||
private struct PairingResolvedEvent: Codable {
|
||||
let requestId: String
|
||||
let nodeId: String
|
||||
let decision: String
|
||||
let ts: Double
|
||||
}
|
||||
|
||||
private func startPushTask() {
|
||||
PairingAlertSupport.startPairingPushTask(
|
||||
task: &self.task,
|
||||
isStopping: &self.isStopping,
|
||||
loadPending: self.loadPendingRequestsFromGateway,
|
||||
handlePush: self.handle(push:))
|
||||
private enum PairingResolution: String {
|
||||
case approved
|
||||
case rejected
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
self.isStopping = false
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = nil
|
||||
self.task = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = try? await GatewayConnection.shared.refresh()
|
||||
await self.loadPendingRequestsFromGateway()
|
||||
let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await MainActor.run { [weak self] in self?.handle(push: push) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.stopPushTask()
|
||||
self.isStopping = true
|
||||
self.endActiveAlert()
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
self.reconcileTask?.cancel()
|
||||
self.reconcileTask = nil
|
||||
self.reconcileOnceTask?.cancel()
|
||||
self.reconcileOnceTask = nil
|
||||
self.queue.removeAll(keepingCapacity: false)
|
||||
self.updatePendingCounts()
|
||||
self.isPresenting = false
|
||||
self.activeRequestId = nil
|
||||
self.alertHostWindow?.orderOut(nil)
|
||||
self.alertHostWindow?.close()
|
||||
self.alertHostWindow = nil
|
||||
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
|
||||
self.autoApproveAttempts.removeAll(keepingCapacity: false)
|
||||
}
|
||||
|
||||
private func stopPushTask() {
|
||||
PairingAlertSupport.stopPairingPrompter(
|
||||
isStopping: &self.isStopping,
|
||||
task: &self.task,
|
||||
queue: &self.queue,
|
||||
isPresenting: &self.isPresenting,
|
||||
state: self.alertState)
|
||||
}
|
||||
|
||||
private func loadPendingRequestsFromGateway() async {
|
||||
// The gateway process may start slightly after the app. Retry a bit so
|
||||
// pending pairing prompts are still shown on launch.
|
||||
@@ -176,7 +190,7 @@ final class NodePairingApprovalPrompter {
|
||||
if pendingById[req.requestId] != nil { continue }
|
||||
let resolution = self.inferResolution(for: req, list: list)
|
||||
|
||||
if self.alertState.activeRequestId == req.requestId, self.alertState.activeAlert != nil {
|
||||
if self.activeRequestId == req.requestId, self.activeAlert != nil {
|
||||
self.remoteResolutionsByRequestId[req.requestId] = resolution
|
||||
self.logger.info(
|
||||
"""
|
||||
@@ -218,7 +232,11 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func endActiveAlert() {
|
||||
PairingAlertSupport.endActiveAlert(state: self.alertState)
|
||||
PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
|
||||
}
|
||||
|
||||
private func requireAlertHostWindow() -> NSWindow {
|
||||
PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
|
||||
}
|
||||
|
||||
private func handle(push: GatewayPush) {
|
||||
@@ -275,13 +293,47 @@ final class NodePairingApprovalPrompter {
|
||||
|
||||
private func presentAlert(for req: PendingRequest) {
|
||||
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
|
||||
PairingAlertSupport.presentPairingAlert(
|
||||
request: req,
|
||||
requestId: req.requestId,
|
||||
messageText: "Allow node to connect?",
|
||||
informativeText: Self.describe(req),
|
||||
state: self.alertState,
|
||||
onResponse: self.handleAlertResponse)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow node to connect?"
|
||||
alert.informativeText = Self.describe(req)
|
||||
// Fail-safe ordering: if the dialog can't be presented, default to "Later".
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
|
||||
alert.buttons[2].hasDestructiveAction = true
|
||||
}
|
||||
|
||||
self.activeAlert = alert
|
||||
self.activeRequestId = req.requestId
|
||||
let hostWindow = self.requireAlertHostWindow()
|
||||
|
||||
// Position the hidden host window so the sheet appears centered on screen.
|
||||
// (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".)
|
||||
let sheetSize = alert.window.frame.size
|
||||
if let screen = hostWindow.screen ?? NSScreen.main {
|
||||
let bounds = screen.visibleFrame
|
||||
let x = bounds.midX - (sheetSize.width / 2)
|
||||
let sheetOriginY = bounds.midY - (sheetSize.height / 2)
|
||||
let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
|
||||
hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
|
||||
} else {
|
||||
hostWindow.center()
|
||||
}
|
||||
|
||||
hostWindow.makeKeyAndOrderFront(nil)
|
||||
alert.beginSheetModal(for: hostWindow) { [weak self] response in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeRequestId = nil
|
||||
self.activeAlert = nil
|
||||
await self.handleAlertResponse(response, request: req)
|
||||
hostWindow.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
|
||||
@@ -321,22 +373,24 @@ final class NodePairingApprovalPrompter {
|
||||
}
|
||||
|
||||
private func approve(requestId: String) async -> Bool {
|
||||
await PairingAlertSupport.approveRequest(
|
||||
requestId: requestId,
|
||||
kind: "node",
|
||||
logger: self.logger)
|
||||
{
|
||||
do {
|
||||
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
|
||||
self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func reject(requestId: String) async {
|
||||
await PairingAlertSupport.rejectRequest(
|
||||
requestId: requestId,
|
||||
kind: "node",
|
||||
logger: self.logger)
|
||||
{
|
||||
do {
|
||||
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
|
||||
self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
|
||||
self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +419,8 @@ final class NodePairingApprovalPrompter {
|
||||
private static func prettyPlatform(_ platform: String?) -> String? {
|
||||
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let raw, !raw.isEmpty else { return nil }
|
||||
if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty }
|
||||
if raw.lowercased() == "ios" { return "iOS" }
|
||||
if raw.lowercased() == "macos" { return "macOS" }
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -561,7 +616,7 @@ final class NodePairingApprovalPrompter {
|
||||
let resolution: PairingResolution =
|
||||
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
|
||||
|
||||
if self.alertState.activeRequestId == resolved.requestId, self.alertState.activeAlert != nil {
|
||||
if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
|
||||
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
|
||||
self.logger.info(
|
||||
"""
|
||||
|
||||
@@ -103,9 +103,15 @@ extension NodeServiceManager {
|
||||
}
|
||||
|
||||
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
||||
guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
|
||||
let jsonText = parsed.text
|
||||
let object = parsed.object
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let ok = object["ok"] as? Bool
|
||||
let result = object["result"] as? String
|
||||
let message = object["message"] as? String
|
||||
@@ -133,6 +139,12 @@ extension NodeServiceManager {
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
TextSummarySupport.summarizeLastLine(text)
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ struct NodeMenuEntryFormatter {
|
||||
|
||||
static func platformText(_ entry: NodeInfo) -> String? {
|
||||
if let raw = entry.platform?.nonEmpty {
|
||||
return PlatformLabelFormatter.pretty(raw) ?? raw
|
||||
return self.prettyPlatform(raw) ?? raw
|
||||
}
|
||||
if let family = entry.deviceFamily?.lowercased() {
|
||||
if family.contains("mac") { return "macOS" }
|
||||
@@ -79,6 +79,34 @@ struct NodeMenuEntryFormatter {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func prettyPlatform(_ raw: String) -> String? {
|
||||
let (prefix, version) = self.parsePlatform(raw)
|
||||
if prefix.isEmpty { return nil }
|
||||
let name: String = switch prefix {
|
||||
case "macos": "macOS"
|
||||
case "ios": "iOS"
|
||||
case "ipados": "iPadOS"
|
||||
case "tvos": "tvOS"
|
||||
case "watchos": "watchOS"
|
||||
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
|
||||
}
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
let parts = version.split(separator: ".").map(String.init)
|
||||
if parts.count >= 2 {
|
||||
return "\(name) \(parts[0]).\(parts[1])"
|
||||
}
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
|
||||
private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return ("", nil) }
|
||||
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
let prefix = parts.first?.lowercased() ?? ""
|
||||
let versionToken = parts.dropFirst().first
|
||||
return (prefix, versionToken)
|
||||
}
|
||||
|
||||
private static func compactVersion(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return trimmed }
|
||||
@@ -173,8 +201,12 @@ struct NodeMenuRowView: View {
|
||||
let width: CGFloat
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
private var palette: MenuItemHighlightColors.Palette {
|
||||
MenuItemHighlightColors.palette(self.isHighlighted)
|
||||
private var primaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -184,9 +216,9 @@ struct NodeMenuRowView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
|
||||
.foregroundStyle(self.primaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
@@ -195,9 +227,9 @@ struct NodeMenuRowView: View {
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(2)
|
||||
@@ -205,7 +237,7 @@ struct NodeMenuRowView: View {
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
@@ -213,7 +245,7 @@ struct NodeMenuRowView: View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
@@ -222,7 +254,7 @@ struct NodeMenuRowView: View {
|
||||
if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) {
|
||||
Text(version)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
@@ -241,11 +273,11 @@ struct NodeMenuRowView: View {
|
||||
private var leadingIcon: some View {
|
||||
if NodeMenuEntryFormatter.isAndroid(self.entry) {
|
||||
AndroidMark()
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
} else {
|
||||
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
|
||||
.font(.system(size: 18, weight: .regular))
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -273,19 +305,23 @@ struct NodeMenuMultilineView: View {
|
||||
let width: CGFloat
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
private var palette: MenuItemHighlightColors.Palette {
|
||||
MenuItemHighlightColors.palette(self.isHighlighted)
|
||||
private var primaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(self.label):")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
|
||||
Text(self.value)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.palette.primary)
|
||||
.foregroundStyle(self.primaryColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,14 @@ final class NodesStore {
|
||||
func start() {
|
||||
self.startCount += 1
|
||||
guard self.startCount == 1 else { return }
|
||||
SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
|
||||
await self?.refresh()
|
||||
guard self.task == nil else { return }
|
||||
self.task = Task.detached { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
|
||||
await self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,17 @@ final class NotifyOverlayController {
|
||||
self.dismissTask = nil
|
||||
guard let window else { return }
|
||||
|
||||
OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 8, offsetY: 6) {
|
||||
self.model.isVisible = false
|
||||
let target = window.frame.offsetBy(dx: 8, dy: 6)
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.16
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 0
|
||||
} completionHandler: {
|
||||
Task { @MainActor in
|
||||
window.orderOut(nil)
|
||||
self.model.isVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,21 +70,44 @@ final class NotifyOverlayController {
|
||||
self.ensureWindow()
|
||||
self.hostingView?.rootView = NotifyOverlayView(controller: self)
|
||||
let target = self.targetFrame()
|
||||
OverlayPanelFactory.present(
|
||||
window: self.window,
|
||||
isVisible: &self.model.isVisible,
|
||||
target: target) { window in
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
|
||||
guard let window else { return }
|
||||
if !self.model.isVisible {
|
||||
self.model.isVisible = true
|
||||
let start = target.offsetBy(dx: 0, dy: -6)
|
||||
window.setFrame(start, display: true)
|
||||
window.alphaValue = 0
|
||||
window.orderFrontRegardless()
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.18
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(target, display: true)
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
} else {
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow() {
|
||||
if self.window != nil { return }
|
||||
let panel = OverlayPanelFactory.makePanel(
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight),
|
||||
level: .statusBar,
|
||||
hasShadow: true)
|
||||
styleMask: [.nonactivatingPanel, .borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = true
|
||||
panel.level = .statusBar
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.isMovable = false
|
||||
panel.isFloatingPanel = true
|
||||
panel.becomesKeyOnlyIfNeeded = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.titlebarAppearsTransparent = true
|
||||
|
||||
let host = NSHostingView(rootView: NotifyOverlayView(controller: self))
|
||||
host.translatesAutoresizingMaskIntoConstraints = false
|
||||
@@ -94,7 +126,17 @@ final class NotifyOverlayController {
|
||||
}
|
||||
|
||||
private func updateWindowFrame(animate: Bool = false) {
|
||||
OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
|
||||
guard let window else { return }
|
||||
let frame = self.targetFrame()
|
||||
if animate {
|
||||
NSAnimationContext.runAnimationGroup { context in
|
||||
context.duration = 0.12
|
||||
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
window.animator().setFrame(frame, display: true)
|
||||
}
|
||||
} else {
|
||||
window.setFrame(frame, display: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func measuredHeight() -> CGFloat {
|
||||
|
||||
@@ -24,7 +24,19 @@ extension OnboardingView {
|
||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
|
||||
|
||||
if self.state.remoteTransport == .direct {
|
||||
self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
|
||||
self.state.connectionMode = .remote
|
||||
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||
|
||||
@@ -189,7 +189,19 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
|
||||
self.featureRowContent(title: title, subtitle: subtitle, systemImage: systemImage)
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 26)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
func featureActionRow(
|
||||
@@ -198,22 +210,6 @@ extension OnboardingView {
|
||||
systemImage: String,
|
||||
buttonTitle: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
self.featureRowContent(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
systemImage: systemImage,
|
||||
action: AnyView(
|
||||
Button(buttonTitle, action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)))
|
||||
}
|
||||
|
||||
private func featureRowContent(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
action: AnyView? = nil) -> some View
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
@@ -225,9 +221,9 @@ extension OnboardingView {
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if let action {
|
||||
action
|
||||
}
|
||||
Button(buttonTitle, action: action)
|
||||
.buttonStyle(.link)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user