mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 15:01:17 +08:00
Compare commits
182 Commits
flat-decla
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2afd1c0077 | ||
|
|
b261e9e6dd | ||
|
|
e707b452c0 | ||
|
|
79e733cc34 | ||
|
|
f8c8c0d41e | ||
|
|
8dc9cfe734 | ||
|
|
e5adde9fe3 | ||
|
|
ad3e3cb7d2 | ||
|
|
5216841a9e | ||
|
|
b601550c97 | ||
|
|
ad1d8bf990 | ||
|
|
049c1158c9 | ||
|
|
81c90aab6b | ||
|
|
85277c2db1 | ||
|
|
9adbab05c6 | ||
|
|
83bb5fb994 | ||
|
|
b6ef874220 | ||
|
|
68e6f03fd9 | ||
|
|
7b5f0c23e5 | ||
|
|
3e2994b975 | ||
|
|
2c3d7f5bad | ||
|
|
dab3152e0e | ||
|
|
3fea219692 | ||
|
|
3f3ed5ec66 | ||
|
|
f6e51ff99a | ||
|
|
65d47dc5d7 | ||
|
|
b4741302c6 | ||
|
|
76f447b250 | ||
|
|
bc6ecc89d5 | ||
|
|
47fdd6b88b | ||
|
|
80909b3265 | ||
|
|
c7891ec67e | ||
|
|
910354b07f | ||
|
|
844d263af0 | ||
|
|
27d57af127 | ||
|
|
b667bdd622 | ||
|
|
3cb7ae5350 | ||
|
|
b58786ce9f | ||
|
|
ff5886bba2 | ||
|
|
f2f18f5958 | ||
|
|
8ba71e4aff | ||
|
|
44451eaa47 | ||
|
|
865678eb6b | ||
|
|
38f3040384 | ||
|
|
bda924b639 | ||
|
|
8677310fb5 | ||
|
|
e856932600 | ||
|
|
a048cbc4f0 | ||
|
|
8e3be0a705 | ||
|
|
76ebc14956 | ||
|
|
3d89f493ba | ||
|
|
a5eddb91bb | ||
|
|
56302f79a8 | ||
|
|
dc31f73b39 | ||
|
|
5809bdf0cb | ||
|
|
97ed582f1c | ||
|
|
6eedc8331b | ||
|
|
6835f05cd0 | ||
|
|
d7e62a87f2 | ||
|
|
f48a89cb1c | ||
|
|
aa82b43c9f | ||
|
|
a854331c4c | ||
|
|
3fb67467fa | ||
|
|
51e57d46cf | ||
|
|
e5a687f115 | ||
|
|
17c1b06cc7 | ||
|
|
bda3531560 | ||
|
|
aab5410bd5 | ||
|
|
e0635eb6fd | ||
|
|
4252f07ff0 | ||
|
|
4ce3c3e36c | ||
|
|
653e8d1ea5 | ||
|
|
98d6331d10 | ||
|
|
2b0e399da1 | ||
|
|
b234aa0085 | ||
|
|
cee364e2f6 | ||
|
|
da551463e3 | ||
|
|
2252cf6f03 | ||
|
|
9a7f808953 | ||
|
|
eb273a8a4a | ||
|
|
259796dc3d | ||
|
|
d64b394537 | ||
|
|
88c395c83c | ||
|
|
9085d17ab6 | ||
|
|
4a2b02e86f | ||
|
|
beb25d60f7 | ||
|
|
4bd711e1c4 | ||
|
|
3844e035bb | ||
|
|
9fef53c3b1 | ||
|
|
91a4635bdc | ||
|
|
629fc2f8f0 | ||
|
|
1bc32e53ab | ||
|
|
93577ad587 | ||
|
|
bb418a857e | ||
|
|
dc5671edae | ||
|
|
f9aec04167 | ||
|
|
b008989bef | ||
|
|
7275304793 | ||
|
|
9ebf51efe9 | ||
|
|
98052028aa | ||
|
|
13dcded7c8 | ||
|
|
4c3a0292ff | ||
|
|
bd02977e29 | ||
|
|
9f7006407f | ||
|
|
b005f01c13 | ||
|
|
e397636051 | ||
|
|
23f494cba9 | ||
|
|
744da7e6bd | ||
|
|
5da34a982b | ||
|
|
a0cf1858a2 | ||
|
|
8d5f6c8ae4 | ||
|
|
1395d71821 | ||
|
|
39bc43cb60 | ||
|
|
05f357b13b | ||
|
|
bd6a404aa3 | ||
|
|
0ade360da5 | ||
|
|
00fb15253c | ||
|
|
ea48ac7da8 | ||
|
|
50a708c5f9 | ||
|
|
02b1a2168c | ||
|
|
13427276b8 | ||
|
|
97717277c4 | ||
|
|
ca1829c3f4 | ||
|
|
43deaf4621 | ||
|
|
c16620cb07 | ||
|
|
55e1878e57 | ||
|
|
47c67e31ab | ||
|
|
062d429d9c | ||
|
|
580e95fad1 | ||
|
|
dcb00f3d8e | ||
|
|
748015b42f | ||
|
|
ae0f46927d | ||
|
|
5f3012bc70 | ||
|
|
b0517f1f54 | ||
|
|
5058fc94b3 | ||
|
|
d4ffac4597 | ||
|
|
384dd1216e | ||
|
|
6c858ac65f | ||
|
|
d3751e409f | ||
|
|
831bb456f7 | ||
|
|
71781b82b4 | ||
|
|
127c0ad418 | ||
|
|
e805ffd2eb | ||
|
|
53704b26e8 | ||
|
|
44027e72d0 | ||
|
|
d1bca0c32c | ||
|
|
8f6a2f0f6b | ||
|
|
4a45a259ec | ||
|
|
d10d30c5fa | ||
|
|
4f26cc9090 | ||
|
|
f7c32fc8be | ||
|
|
51d7f3c143 | ||
|
|
c841218ace | ||
|
|
647e18aa04 | ||
|
|
771ddcf184 | ||
|
|
5f3d6cde19 | ||
|
|
633c40aa65 | ||
|
|
ec3ac182c5 | ||
|
|
6ae4a00a66 | ||
|
|
a0ba9f2b72 | ||
|
|
313d6ae1b3 | ||
|
|
8d21ac3f6e | ||
|
|
96b8df75d5 | ||
|
|
6adf2340fb | ||
|
|
736e04cb90 | ||
|
|
6a324f6400 | ||
|
|
b860a0d4d0 | ||
|
|
751cd0c9b8 | ||
|
|
f5e48f767f | ||
|
|
d165100c93 | ||
|
|
5887119e8d | ||
|
|
bf22893cb6 | ||
|
|
edd4c62da1 | ||
|
|
6fe7dddcf2 | ||
|
|
3ef34702c8 | ||
|
|
e0d003b372 | ||
|
|
2229122077 | ||
|
|
8b78ded074 | ||
|
|
ac28c0611d | ||
|
|
3005b62242 | ||
|
|
b6e354f6ca | ||
|
|
d1577a2ff2 |
@@ -98,7 +98,7 @@ Do not close from title alone. If closing as done on main or nonsensical, prove
|
||||
|
||||
When asked for `5 new`, exclude refs already surfaced in the session and refill from the archive until there are 5 live-open candidates. If fewer than 5 remain open, list all open ones and say how many short.
|
||||
|
||||
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Do not fill the main list with items that merely merged/closed since the last pass; put those numbers in a short bottom line.
|
||||
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Sort by maintainer importance, not recency: high-impact ready fixes first, then useful-but-review-first, then open/not-ready items. Do not include a "changed since last pass" section or bottom-line merged/closed summary unless the user explicitly asks for churn.
|
||||
|
||||
Prefer:
|
||||
|
||||
@@ -142,18 +142,20 @@ No Markdown tables. Compact bullets. Use color/risk markers:
|
||||
Required line shape:
|
||||
|
||||
```markdown
|
||||
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 verifiable: yes. This prevents chat action buttons from overlapping short assistant replies. Blast: web chat rendering, low.
|
||||
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 verifiable: partial. This reports duplicate Telegram replies when reconnecting after gateway restart. Blast: Telegram channel runtime, medium.
|
||||
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 https://github.com/openclaw/openclaw/pull/81244 - Prevents chat action buttons from overlapping short assistant replies. Verifiable: yes. Blast: web chat rendering, low.
|
||||
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 https://github.com/openclaw/openclaw/issues/81245 - Reports duplicate Telegram replies when reconnecting after gateway restart. Verifiable: partial. Blast: Telegram channel runtime, medium.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Bold the `PR #n` or `Issue #n` marker.
|
||||
- Use `@handle`, not author bio text.
|
||||
- Always include the full GitHub URL.
|
||||
- Include a one-line description after the URL, separated with `-`.
|
||||
- PR LOC is `+additions/-deletions`; issue LOC is `LOC n/a`.
|
||||
- Type: `bug`, `feature`, `perf`, `security`, `docs`, `test`, `chore`, or `refactor`.
|
||||
- Write a full sentence for what it does.
|
||||
- Always include blast radius in one phrase.
|
||||
- Always include `verifiable: yes|partial|no` plus the shortest proof hint when helpful.
|
||||
- If status is not open, still show it only when the user asked for all surfaced refs; use ✅ or ⚪ and state merged/closed.
|
||||
- For refresh-style asks, bottom line: `Merged/closed since last pass: #81016 merged, #81026 closed.` Omit if none.
|
||||
- For refresh-style asks, prefer section order: `Best Open Now`, `Useful But Review First`, `Still Open / Not Ready`. Omit merged/closed churn by default.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: openclaw-ghsa-maintainer
|
||||
description: Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state.
|
||||
description: "Inspect, patch, validate, publish, or confirm OpenClaw GHSA security advisories and private-fork state."
|
||||
---
|
||||
|
||||
# OpenClaw GHSA Maintainer
|
||||
@@ -85,3 +85,4 @@ jq -r .description < /tmp/ghsa.refetch.json | rg '\\\\n'
|
||||
- Publishing fails with HTTP 422 if required fields are missing or the private fork still has open PRs.
|
||||
- A payload that looks correct in shell can still be wrong if Markdown was assembled with escaped newline strings.
|
||||
- Advisory PATCH sequencing matters; separate field updates when GHSA API constraints require it.
|
||||
- Public hardening/no-publish comments and draft text should avoid raw commit hashes, PR titles/numbers, and fix-mechanism summaries. Prefer patched-version fields or release-only wording; keep SHAs, PRs, and implementation notes in internal evidence.
|
||||
|
||||
@@ -213,7 +213,7 @@ workflow only spends setup and queue time on that suite.
|
||||
### Release Evidence
|
||||
|
||||
After release-candidate validation or before a release decision, record the
|
||||
important run ids in the private `openclaw/releases-private` evidence ledger.
|
||||
important run ids in the public `openclaw/releases` evidence ledger.
|
||||
Use the manual `OpenClaw Release Evidence`
|
||||
(`openclaw-release-evidence.yml`) workflow there. It writes durable summaries
|
||||
under `evidence/<release-id>/` and commits:
|
||||
@@ -236,13 +236,13 @@ short release-manager notes there. Do not store raw logs, provider
|
||||
prompts/responses, channel transcripts, signing material, or secret-bearing
|
||||
config in git; raw logs stay in Actions artifacts.
|
||||
|
||||
When `Full Release Validation` completes and
|
||||
`OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN` is configured in the public repo, it
|
||||
requests the private `OpenClaw Release Evidence From Full Validation` workflow.
|
||||
That private workflow reads the parent full-validation run, extracts the child
|
||||
CI/release-checks/Telegram run ids from the parent logs, and opens the evidence
|
||||
PR automatically. If the token is absent or the run predates this wiring, trigger
|
||||
that private workflow manually with the full-validation run id.
|
||||
When `Full Release Validation` completes and `OPENCLAW_RELEASES_DISPATCH_TOKEN`
|
||||
is configured in the source repo, it requests the public
|
||||
`OpenClaw Release Evidence From Full Validation` workflow. That workflow reads
|
||||
the parent full-validation run, extracts the child CI/release-checks/Telegram
|
||||
run ids from the parent logs, and opens the evidence PR automatically. If the
|
||||
token is absent or the run predates this wiring, trigger that workflow manually
|
||||
with the full-validation run id.
|
||||
|
||||
### Release Checks
|
||||
|
||||
|
||||
85
.agents/skills/release-openclaw-announcement/SKILL.md
Normal file
85
.agents/skills/release-openclaw-announcement/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: release-openclaw-announcement
|
||||
description: "Draft or post OpenClaw beta/stable Discord release announcements from changelog, GitHub release, registry, and validation evidence. Use when announcing a beta, stable release, release candidate, or asking what users should test after an OpenClaw release."
|
||||
---
|
||||
|
||||
# OpenClaw Release Announcement
|
||||
|
||||
Use with `release-openclaw-maintainer` after a beta or stable release is live.
|
||||
Use with `openclaw-discord` when actually posting to Discord.
|
||||
|
||||
## Evidence First
|
||||
|
||||
Before drafting focus areas, read real release evidence:
|
||||
|
||||
1. Current GitHub release body for the tag.
|
||||
2. `CHANGELOG.md` section for the released base version.
|
||||
3. Commits since the previous shipped version or the operator-specified base.
|
||||
4. Registry/package metadata for the exact version and current dist-tag.
|
||||
5. Validation status that is relevant to user confidence.
|
||||
|
||||
Do not claim a full changelog audit unless you did it. If you only read the
|
||||
generated release notes or top changelog section, say that and either audit
|
||||
properly or draft with that limitation.
|
||||
|
||||
For beta focus areas, prioritize user-observable changes over internal test or
|
||||
CI mechanics:
|
||||
|
||||
- install/update paths
|
||||
- OS/platform-specific behavior
|
||||
- Gateway startup/restart, config, and runtime behavior
|
||||
- provider/model/runtime routing
|
||||
- plugin loading and local plugin development
|
||||
- channels and media paths
|
||||
- security/data-loss/user-impact fixes
|
||||
|
||||
Do not let late release-branch fixes automatically dominate the announcement.
|
||||
If the version includes a large delta from the previous shipped version, rank
|
||||
focus areas by the whole release delta and expected user impact; mention late
|
||||
fixes in their natural category.
|
||||
|
||||
## Required Copy
|
||||
|
||||
Every beta announcement must make beta status explicit and include:
|
||||
|
||||
- exact version, e.g. `OpenClaw 2026.5.25-beta.1`
|
||||
- one-sentence risk framing: beta, useful for testing, not stable promotion
|
||||
- focused test areas derived from evidence, not guesswork
|
||||
- update command promoted near the top:
|
||||
```sh
|
||||
openclaw update --channel beta --yes
|
||||
openclaw --version
|
||||
```
|
||||
- fresh install path:
|
||||
`Install from https://openclaw.ai`
|
||||
- GitHub release link
|
||||
- concise validation note, without making CI the headline
|
||||
|
||||
Do not suggest npm install commands in beta announcements unless the operator
|
||||
explicitly asks for npm-specific copy or troubleshooting text. It is fine to use
|
||||
registry metadata as evidence; do not turn that into public install guidance.
|
||||
|
||||
For stable announcements, use the stable channel wording:
|
||||
|
||||
```sh
|
||||
openclaw update --channel stable --yes
|
||||
openclaw --version
|
||||
```
|
||||
|
||||
Fresh installs still point to `https://openclaw.ai`.
|
||||
|
||||
## Style
|
||||
|
||||
- Discord Markdown, no tables.
|
||||
- Keep it skimmable: short intro, bullets, commands, links.
|
||||
- Lead with what users can feel or test, not proof plumbing.
|
||||
- Mention validation only after install/update instructions.
|
||||
- Be specific about where feedback is useful.
|
||||
- Do not mention private local proof paths in public announcements.
|
||||
- Do not overstate unverified platforms, channels, or provider behavior.
|
||||
|
||||
## Posting
|
||||
|
||||
When asked to post, use the configured Discord workflow from
|
||||
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
|
||||
For public channels, inspect the final body before sending.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Release Announcement"
|
||||
short_description: "Draft Discord beta/stable release announcements from evidence."
|
||||
default_prompt: "Use this skill to draft an OpenClaw beta or stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence."
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: security-triage
|
||||
description: Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof.
|
||||
description: "Triage OpenClaw security advisories, drafts, and GHSA reports with shipped-tag and trust-model proof."
|
||||
---
|
||||
|
||||
# Security Triage
|
||||
@@ -87,11 +87,19 @@ When preparing a maintainer-ready close reply:
|
||||
- exact reason for close
|
||||
- exact code refs
|
||||
- exact shipped tag / release facts
|
||||
- exact fix commit or canonical duplicate GHSA when applicable
|
||||
- fix provenance or canonical duplicate GHSA when applicable
|
||||
- optional hardening note only if worthwhile and functionality-preserving
|
||||
|
||||
Keep tone firm, specific, non-defensive.
|
||||
|
||||
## Public Wording Hygiene
|
||||
|
||||
- Keep raw commit hashes, PR titles/numbers, and fix-mechanism summaries out of public advisory text. Use the patched release/version field only.
|
||||
- Keep exact commit SHAs, PRs, and implementation notes in internal notes and verification files.
|
||||
- For hardening/no-publish outcomes, do not add exploit-heavy details, "Fixed by" text, or a "Fix Commit(s)" section. Thank reporters, preserve credit, state the `SECURITY.md` boundary, and say clearly that the GHSA will close without publication.
|
||||
- For published CVE/GHSA text, prefer `### Patched Versions` with the fixed release. Do not explain how the patch works unless Peter explicitly asks for that public detail.
|
||||
- Keep GHSA ids out of changelog and release-note wording unless Peter explicitly asks.
|
||||
|
||||
## Discussion Mode
|
||||
|
||||
When Peter is manually posting GHSA comments, use this flow:
|
||||
|
||||
87
.agents/skills/verify-release/SKILL.md
Normal file
87
.agents/skills/verify-release/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: verify-release
|
||||
description: "Verify an OpenClaw release is fully published across GitHub, npm, plugins, ClawHub, package smoke, and live Gateway agent turns."
|
||||
---
|
||||
|
||||
# Verify Release
|
||||
|
||||
Use this when asked whether an OpenClaw release is fully released, published,
|
||||
promoted, smoke-tested, or live-verified. This is a verification skill, not a
|
||||
publish skill; use `$release-openclaw-maintainer` before changing release state.
|
||||
|
||||
## Rules
|
||||
|
||||
- Resolve short suffixes like `.27` to the concrete CalVer version from the
|
||||
current date/context, then say the resolved version.
|
||||
- Verify live state. Do not trust local checkout state, release notes, or old
|
||||
memory as current truth.
|
||||
- If the checkout is dirty or divergent, use it only for scripts/reference.
|
||||
For version metadata, fetch from GitHub release/tag or unpack the tag tarball
|
||||
under `/tmp`.
|
||||
- Never print secrets. Use inherited live keys only for scoped smoke commands.
|
||||
- Keep the final terse: `yes/no`, evidence bullets, caveats, cleanup.
|
||||
|
||||
## Core Checks
|
||||
|
||||
1. GitHub release:
|
||||
- `gh release view v<VERSION> --repo openclaw/openclaw --json tagName,name,publishedAt,isDraft,isPrerelease,targetCommitish,url,body,assets`
|
||||
- Confirm stable releases are not draft/prerelease.
|
||||
- Confirm release body has npm, CI, plugin npm, ClawHub, mac/appcast evidence
|
||||
links when expected.
|
||||
- Confirm assets expected for stable mac releases are uploaded: zip, dmg,
|
||||
dSYM, dependency evidence when present.
|
||||
2. Root npm:
|
||||
- `npm view openclaw@<VERSION> version dist-tags.latest dist.tarball dist.integrity time.<VERSION> --json`
|
||||
- `latest` must equal `<VERSION>` for stable.
|
||||
- Record tarball, integrity, publish time.
|
||||
3. Plugin publish set:
|
||||
- Get exact tag metadata from GitHub, not the local checkout when dirty:
|
||||
download `https://api.github.com/repos/openclaw/openclaw/tarball/v<VERSION>`
|
||||
into `/tmp/openclaw-v<VERSION>-src`.
|
||||
- Count `extensions/*/package.json` with
|
||||
`openclaw.release.publishToNpm === true` and
|
||||
`openclaw.release.publishToClawHub === true`.
|
||||
- Compare expected counts to workflow job counts:
|
||||
`gh api repos/openclaw/openclaw/actions/runs/<RUN>/jobs --paginate`.
|
||||
- Each expected npm plugin must have version `<VERSION>` and
|
||||
`dist-tags.latest === <VERSION>`.
|
||||
4. ClawHub:
|
||||
- Check the Plugin ClawHub Release workflow conclusion and publish job count.
|
||||
- Use OpenClaw itself for live registry proof:
|
||||
`openclaw plugins search <known-plugin> --json`.
|
||||
- Install one official plugin from ClawHub in an isolated HOME:
|
||||
`openclaw plugins install clawhub:@openclaw/matrix --pin`.
|
||||
Prefer `matrix` unless that plugin is not in the expected set.
|
||||
5. Release workflows:
|
||||
- Verify conclusions for release notes evidence links:
|
||||
Full Release Validation, OpenClaw Release Checks, OpenClaw NPM Release,
|
||||
Plugin NPM Release, Plugin ClawHub Release, mac preflight/validation/publish
|
||||
when stable mac assets are expected.
|
||||
- Summarize only relevant successful/failed jobs; ignore routine skipped
|
||||
optional lanes unless the release body promised them.
|
||||
6. Published package smoke:
|
||||
- In `/tmp`, isolated HOME:
|
||||
`npm exec --yes --package openclaw@<VERSION> -- openclaw --version`.
|
||||
- Run at least one harmless command that touches the published CLI surface,
|
||||
for example `plugins --help` or `gateway --help`.
|
||||
7. Dev Gateway live model smoke:
|
||||
- Use temp HOME/workspace, not the user's normal state:
|
||||
`HOME=/tmp/openclaw-release-smoke/home OPENCLAW_WORKSPACE=/tmp/openclaw-release-smoke/work pnpm openclaw --dev gateway run --auth none --force --verbose`.
|
||||
- Health check via CLI: `openclaw --dev gateway health --json`.
|
||||
- Run one Gateway-backed agent turn with inherited `OPENAI_API_KEY`, short
|
||||
prompt, explicit session key, JSON output, and a known-available model.
|
||||
- If the configured default model fails as unavailable, record that caveat
|
||||
and retry with the newest known-good OpenAI model instead of declaring the
|
||||
release failed.
|
||||
- Stop the gateway and verify the port is not listening.
|
||||
|
||||
## Caveats To Report
|
||||
|
||||
- Dist-tag caveat: stable `latest` is release truth; if optional `beta` mirrors
|
||||
still point at a beta version, report it as a caveat, not a stable-release
|
||||
blocker, unless the user asked to verify beta promotion.
|
||||
- Divergent checkout caveat: say when local source SHA differs from release tag
|
||||
or origin and which live sources were used instead.
|
||||
- Smoke caveat: distinguish Gateway-backed agent success from local embedded
|
||||
fallback. A valid Gateway smoke has health OK plus gateway log/run id for the
|
||||
agent call.
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -11,6 +11,8 @@ body:
|
||||
Do not speculate or infer beyond the evidence. If a narrative section cannot be answered from the available evidence, respond with exactly `NOT_ENOUGH_INFO`.
|
||||
|
||||
If this is a plugin beta-release blocker, rename the issue title to `Beta blocker: <plugin-name> - <summary>` and apply the `beta-blocker` label after filing.
|
||||
|
||||
Please only report one issue per submission. Break multiple issues up into separate submissions.
|
||||
- type: dropdown
|
||||
id: bug_type
|
||||
attributes:
|
||||
|
||||
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -490,9 +490,6 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build:ci-artifacts
|
||||
|
||||
- name: Build Control UI
|
||||
run: pnpm ui:build
|
||||
|
||||
- name: Check Control UI i18n
|
||||
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
||||
run: pnpm ui:i18n:check
|
||||
@@ -585,6 +582,20 @@ jobs:
|
||||
pids+=("$!")
|
||||
}
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
gateway_watch_log="${RUNNER_TEMP}/gateway-watch.log"
|
||||
echo "starting gateway-watch: node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000"
|
||||
if node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000 >"$gateway_watch_log" 2>&1; then
|
||||
result="success"
|
||||
else
|
||||
result="failure"
|
||||
fi
|
||||
echo "::group::gateway-watch log"
|
||||
cat "$gateway_watch_log"
|
||||
echo "::endgroup::"
|
||||
results["gateway-watch"]="$result"
|
||||
fi
|
||||
|
||||
if [ "$RUN_CHANNELS" = "true" ]; then
|
||||
start_check "channels" env \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 \
|
||||
@@ -599,10 +610,6 @@ jobs:
|
||||
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
|
||||
fi
|
||||
|
||||
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
||||
start_check "gateway-watch" node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
|
||||
fi
|
||||
|
||||
for index in "${!pids[@]}"; do
|
||||
name="${names[$index]}"
|
||||
log="${logs[$index]}"
|
||||
|
||||
9
.github/workflows/codeql.yml
vendored
9
.github/workflows/codeql.yml
vendored
@@ -19,6 +19,15 @@ on:
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
|
||||
193
.github/workflows/docker-release.yml
vendored
193
.github/workflows/docker-release.yml
vendored
@@ -75,6 +75,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
browser_digest: ${{ steps.build-browser.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -102,14 +103,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-amd64")
|
||||
slim_tags+=("${IMAGE}:main-slim-amd64")
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-amd64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-amd64")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:${version}-browser-amd64")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}"
|
||||
@@ -119,6 +124,9 @@ jobs:
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
echo "browser<<EOF"
|
||||
printf "%s\n" "${browser_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (amd64)
|
||||
@@ -162,6 +170,27 @@ jobs:
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Build and push amd64 browser image
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: |
|
||||
type=gha,scope=docker-release-amd64
|
||||
type=gha,scope=docker-release-browser-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-browser-amd64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_INSTALL_BROWSER=1
|
||||
tags: ${{ steps.tags.outputs.browser }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Smoke test amd64 runtime workspace templates
|
||||
shell: bash
|
||||
env:
|
||||
@@ -206,6 +235,26 @@ jobs:
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Smoke test amd64 browser image
|
||||
if: steps.tags.outputs.browser != ''
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REFS: ${{ steps.tags.outputs.browser }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t image_refs <<< "${IMAGE_REFS}"
|
||||
image_ref="${image_refs[0]}"
|
||||
if [[ -z "${image_ref}" ]]; then
|
||||
echo "::error::No amd64 browser image ref resolved"
|
||||
exit 1
|
||||
fi
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"
|
||||
test -n "${browser}"
|
||||
"${browser}" --version
|
||||
'
|
||||
|
||||
# Build arm64 image. Default and slim tags point to the same slim runtime.
|
||||
build-arm64:
|
||||
needs: [approve_manual_backfill]
|
||||
@@ -217,6 +266,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
browser_digest: ${{ steps.build-browser.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -244,14 +294,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-arm64")
|
||||
slim_tags+=("${IMAGE}:main-slim-arm64")
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}-arm64")
|
||||
slim_tags+=("${IMAGE}:${version}-slim-arm64")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:${version}-browser-arm64")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
echo "::error::No arm64 tags resolved for ref ${SOURCE_REF}"
|
||||
@@ -261,6 +315,9 @@ jobs:
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
echo "browser<<EOF"
|
||||
printf "%s\n" "${browser_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve OCI labels (arm64)
|
||||
@@ -304,6 +361,27 @@ jobs:
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Build and push arm64 browser image
|
||||
id: build-browser
|
||||
if: steps.tags.outputs.browser != ''
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: |
|
||||
type=gha,scope=docker-release-arm64
|
||||
type=gha,scope=docker-release-browser-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-browser-arm64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
|
||||
OPENCLAW_INSTALL_BROWSER=1
|
||||
tags: ${{ steps.tags.outputs.browser }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Smoke test arm64 runtime workspace templates
|
||||
shell: bash
|
||||
env:
|
||||
@@ -348,6 +426,26 @@ jobs:
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Smoke test arm64 browser image
|
||||
if: steps.tags.outputs.browser != ''
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REFS: ${{ steps.tags.outputs.browser }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t image_refs <<< "${IMAGE_REFS}"
|
||||
image_ref="${image_refs[0]}"
|
||||
if [[ -z "${image_ref}" ]]; then
|
||||
echo "::error::No arm64 browser image ref resolved"
|
||||
exit 1
|
||||
fi
|
||||
docker run --rm --entrypoint /bin/sh "${image_ref}" -lc '
|
||||
set -eu
|
||||
browser="$(find /home/node/.cache/ms-playwright -maxdepth 5 -type f \( -name chrome -o -name chromium -o -name chrome-headless-shell \) -print | head -1)"
|
||||
test -n "${browser}"
|
||||
"${browser}" --version
|
||||
'
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
needs: [approve_manual_backfill, build-amd64, build-arm64]
|
||||
@@ -382,18 +480,25 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:main-slim")
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
tags+=("${IMAGE}:${version}")
|
||||
slim_tags+=("${IMAGE}:${version}-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:${version}-browser")
|
||||
fi
|
||||
# Manual backfills should only republish the requested version tags.
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
tags+=("${IMAGE}:latest")
|
||||
slim_tags+=("${IMAGE}:slim")
|
||||
tags+=("${IMAGE}:latest" "${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:slim" "${IMAGE}:main-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
@@ -404,25 +509,39 @@ jobs:
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
echo "browser<<EOF"
|
||||
printf "%s\n" "${browser_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.value }}
|
||||
BROWSER_TAGS: ${{ steps.tags.outputs.browser }}
|
||||
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
|
||||
AMD64_BROWSER_DIGEST: ${{ needs.build-amd64.outputs.browser_digest }}
|
||||
ARM64_BROWSER_DIGEST: ${{ needs.build-arm64.outputs.browser_digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t tags <<< "${TAGS}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_DIGEST}" \
|
||||
"${ARM64_DIGEST}"
|
||||
mapfile -t browser_tags <<< "${BROWSER_TAGS}"
|
||||
create_manifest() {
|
||||
local amd64_digest="$1"
|
||||
local arm64_digest="$2"
|
||||
shift 2
|
||||
local args=()
|
||||
for tag in "$@"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" "$amd64_digest" "$arm64_digest"
|
||||
}
|
||||
create_manifest "${AMD64_DIGEST}" "${ARM64_DIGEST}" "${tags[@]}"
|
||||
if [[ -n "${BROWSER_TAGS}" ]]; then
|
||||
create_manifest "${AMD64_BROWSER_DIGEST}" "${ARM64_BROWSER_DIGEST}" "${browser_tags[@]}"
|
||||
fi
|
||||
|
||||
verify-attestations:
|
||||
needs: [create-manifest]
|
||||
@@ -460,21 +579,39 @@ jobs:
|
||||
slim_multi_refs=()
|
||||
amd64_refs=()
|
||||
arm64_refs=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
multi_refs+=("${IMAGE}:main")
|
||||
slim_multi_refs+=("${IMAGE}:main-slim")
|
||||
amd64_refs+=("${IMAGE}:main-amd64" "${IMAGE}:main-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:main-arm64" "${IMAGE}:main-slim-arm64")
|
||||
browser_supported=0
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
tag="${SOURCE_REF#refs/tags/}"
|
||||
git fetch --depth=1 origin "refs/tags/${tag}:refs/tags/${tag}"
|
||||
if git show "${SOURCE_REF}:Dockerfile" | grep -q '^ARG OPENCLAW_INSTALL_BROWSER'; then
|
||||
browser_supported=1
|
||||
fi
|
||||
elif grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
multi_refs+=("${IMAGE}:${version}")
|
||||
slim_multi_refs+=("${IMAGE}:${version}-slim")
|
||||
amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64")
|
||||
amd64_refs+=(
|
||||
"${IMAGE}:${version}-amd64"
|
||||
"${IMAGE}:${version}-slim-amd64"
|
||||
)
|
||||
arm64_refs+=(
|
||||
"${IMAGE}:${version}-arm64"
|
||||
"${IMAGE}:${version}-slim-arm64"
|
||||
)
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
multi_refs+=("${IMAGE}:${version}-browser")
|
||||
amd64_refs+=("${IMAGE}:${version}-browser-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-browser-arm64")
|
||||
fi
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
multi_refs+=("${IMAGE}:latest")
|
||||
slim_multi_refs+=("${IMAGE}:slim")
|
||||
multi_refs+=("${IMAGE}:latest" "${IMAGE}:main")
|
||||
slim_multi_refs+=("${IMAGE}:slim" "${IMAGE}:main-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
multi_refs+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then
|
||||
|
||||
26
.github/workflows/full-release-validation.yml
vendored
26
.github/workflows/full-release-validation.yml
vendored
@@ -80,7 +80,7 @@ on:
|
||||
default: ""
|
||||
type: string
|
||||
evidence_package_spec:
|
||||
description: Optional published package spec to prove in the private release evidence report
|
||||
description: Optional published package spec to prove in the release evidence report
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -1450,9 +1450,9 @@ jobs:
|
||||
|
||||
exit "$failed"
|
||||
|
||||
- name: Request private evidence update
|
||||
- name: Request release evidence update
|
||||
env:
|
||||
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
|
||||
RELEASES_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_DISPATCH_TOKEN }}
|
||||
TARGET_REF: ${{ inputs.ref }}
|
||||
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
|
||||
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
|
||||
@@ -1460,11 +1460,11 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
|
||||
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
|
||||
echo "Release checks were skipped by rerun group; skipping automatic release evidence update."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
|
||||
if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1483,7 +1483,7 @@ jobs:
|
||||
fi
|
||||
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
|
||||
if [[ -z "$release_id" ]]; then
|
||||
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
|
||||
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic release evidence update."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -1509,18 +1509,18 @@ jobs:
|
||||
if ! curl --fail-with-body \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
|
||||
-H "Authorization: Bearer ${RELEASES_DISPATCH_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
https://api.github.com/repos/openclaw/releases/dispatches \
|
||||
-d "$payload"; then
|
||||
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
echo "::warning::Automatic release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
{
|
||||
echo "### Private release evidence dispatch failed"
|
||||
echo "### Release evidence dispatch failed"
|
||||
echo
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases\`:"
|
||||
echo
|
||||
echo "\`\`\`bash"
|
||||
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
|
||||
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
|
||||
echo "\`\`\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
8
.github/workflows/macos-release.yml
vendored
8
.github/workflows/macos-release.yml
vendored
@@ -93,8 +93,8 @@ jobs:
|
||||
echo "It does not sign, notarize, or upload macOS assets."
|
||||
echo
|
||||
echo "Next step:"
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the private mac validation lane to pass."
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full private mac preflight."
|
||||
echo "- For the real publish path, run the same private mac publish workflow from \`main\` with the successful private preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
|
||||
echo "- For stable releases, the private publish workflow also publishes the signed \`appcast.xml\` to public \`main\`, or opens an appcast PR if direct push is blocked."
|
||||
echo "- Run \`openclaw/releases/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the macOS validation lane to pass."
|
||||
echo "- Run \`openclaw/releases/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full macOS preflight."
|
||||
echo "- For the real publish path, run the same macOS publish workflow from \`main\` with the successful preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them."
|
||||
echo "- For stable releases, the publish workflow also publishes the signed \`appcast.xml\` to public \`main\`, or opens an appcast PR if direct push is blocked."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -48,7 +48,8 @@ env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
CRABBOX_REF: main
|
||||
CRABBOX_CAPACITY_REGIONS: eu-west-1,eu-west-2,eu-central-1,us-east-1,us-west-2
|
||||
CRABBOX_AWS_REGION: us-east-1
|
||||
CRABBOX_CAPACITY_REGIONS: us-east-1
|
||||
MANTIS_OUTPUT_DIR: .artifacts/qa-e2e/mantis/telegram-desktop-proof
|
||||
|
||||
jobs:
|
||||
@@ -224,6 +225,7 @@ jobs:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -239,9 +241,6 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
if [[ -n "${PR_NUMBER:-}" ]]; then
|
||||
git fetch --no-tags origin "+refs/pull/${PR_NUMBER}/head:refs/remotes/origin/pr/${PR_NUMBER}" || true
|
||||
fi
|
||||
|
||||
resolve_commit() {
|
||||
local input_ref="$2"
|
||||
@@ -255,7 +254,6 @@ jobs:
|
||||
}
|
||||
|
||||
baseline_revision="$(resolve_commit baseline "$BASELINE_REF")"
|
||||
candidate_revision="$(resolve_commit candidate "$CANDIDATE_REF")"
|
||||
if ! git merge-base --is-ancestor "$baseline_revision" refs/remotes/origin/main; then
|
||||
echo "baseline ref '${BASELINE_REF}' resolved to ${baseline_revision}, which is not on main." >&2
|
||||
exit 1
|
||||
@@ -269,6 +267,11 @@ jobs:
|
||||
pr_state="$(jq -r '.state' <<<"$pr_head")"
|
||||
pr_head_sha="$(jq -r '.head_sha' <<<"$pr_head")"
|
||||
pr_head_repo="$(jq -r '.head_repo' <<<"$pr_head")"
|
||||
candidate_revision="$CANDIDATE_REF"
|
||||
if [[ ! "$candidate_revision" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "candidate ref '${CANDIDATE_REF}' is not an immutable commit SHA." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$pr_state" != "open" || "$candidate_revision" != "$pr_head_sha" ]]; then
|
||||
echo "candidate ref '${CANDIDATE_REF}' resolved to ${candidate_revision}, which is not the open PR head." >&2
|
||||
exit 1
|
||||
@@ -423,7 +426,7 @@ jobs:
|
||||
{
|
||||
printf '%s\n' 'Defaults env_keep += "CODEX_HOME CODEX_INTERNAL_ORIGINATOR_OVERRIDE"'
|
||||
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER CRABBOX_CAPACITY_REGIONS"'
|
||||
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_AWS_REGION CRABBOX_CAPACITY_REGIONS CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
|
||||
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
|
||||
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
|
||||
@@ -452,6 +455,7 @@ jobs:
|
||||
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
|
||||
CRABBOX_COORDINATOR_TOKEN: ${{ secrets.CRABBOX_COORDINATOR_TOKEN || secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
||||
CRABBOX_AWS_REGION: ${{ env.CRABBOX_AWS_REGION }}
|
||||
CRABBOX_CAPACITY_REGIONS: ${{ env.CRABBOX_CAPACITY_REGIONS }}
|
||||
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
|
||||
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
|
||||
|
||||
4
.github/workflows/mantis-telegram-live.yml
vendored
4
.github/workflows/mantis-telegram-live.yml
vendored
@@ -44,6 +44,8 @@ env:
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
CRABBOX_REF: main
|
||||
CRABBOX_AWS_REGION: us-east-1
|
||||
CRABBOX_CAPACITY_REGIONS: us-east-1
|
||||
|
||||
jobs:
|
||||
authorize_actor:
|
||||
@@ -383,6 +385,8 @@ jobs:
|
||||
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
|
||||
CRABBOX_ACCESS_CLIENT_ID: ${{ secrets.CRABBOX_ACCESS_CLIENT_ID }}
|
||||
CRABBOX_ACCESS_CLIENT_SECRET: ${{ secrets.CRABBOX_ACCESS_CLIENT_SECRET }}
|
||||
CRABBOX_AWS_REGION: ${{ env.CRABBOX_AWS_REGION }}
|
||||
CRABBOX_CAPACITY_REGIONS: ${{ env.CRABBOX_CAPACITY_REGIONS }}
|
||||
CRABBOX_LEASE_ID: ${{ needs.resolve_request.outputs.lease_id }}
|
||||
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
|
||||
SCENARIO_INPUT: ${{ needs.resolve_request.outputs.scenario }}
|
||||
|
||||
4
.github/workflows/openclaw-npm-release.yml
vendored
4
.github/workflows/openclaw-npm-release.yml
vendored
@@ -47,8 +47,8 @@ jobs:
|
||||
# KEEP THIS WORKFLOW SHORT AND DETERMINISTIC OR IT CAN GET STUCK AND JEOPARDIZE THE RELEASE.
|
||||
# RELEASE-TIME LIVE OR END-TO-END VALIDATION BELONGS IN openclaw-release-checks.yml.
|
||||
# SECURITY NOTE: TOKEN-BASED npm dist-tag mutation moved to
|
||||
# openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this public workflow can stay focused on OIDC publish only.
|
||||
# openclaw/releases/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this source workflow can stay focused on OIDC publish only.
|
||||
preflight_openclaw_npm:
|
||||
if: ${{ inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
17
.github/workflows/openclaw-performance.yml
vendored
17
.github/workflows/openclaw-performance.yml
vendored
@@ -551,25 +551,31 @@ jobs:
|
||||
retention-days: ${{ matrix.deep_profile == 'true' && 14 || 30 }}
|
||||
|
||||
- name: Prepare clawgrit reports checkout
|
||||
id: clawgrit_reports
|
||||
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
|
||||
env:
|
||||
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
reports_root=".artifacts/clawgrit-reports"
|
||||
mkdir -p "$reports_root"
|
||||
git -C "$reports_root" init -b main
|
||||
git -C "$reports_root" remote add origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
|
||||
if git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
|
||||
git -C "$reports_root" fetch --depth=1 origin main
|
||||
if timeout 60s git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
|
||||
if ! timeout 120s git -C "$reports_root" fetch --depth=1 origin main; then
|
||||
echo "::warning::Skipping optional clawgrit report publish because the reports checkout fetch timed out or failed."
|
||||
exit 0
|
||||
fi
|
||||
git -C "$reports_root" checkout -B main FETCH_HEAD
|
||||
else
|
||||
git -C "$reports_root" checkout -B main
|
||||
fi
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish to clawgrit reports
|
||||
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
|
||||
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' && steps.clawgrit_reports.outputs.ready == 'true' }}
|
||||
env:
|
||||
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
|
||||
shell: bash
|
||||
@@ -642,6 +648,9 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
git -C "$reports_root" fetch --depth=1 origin main
|
||||
timeout 120s git -C "$reports_root" fetch --depth=1 origin main || {
|
||||
echo "::warning::Skipping optional clawgrit report rebase because the reports fetch timed out or failed."
|
||||
exit 0
|
||||
}
|
||||
git -C "$reports_root" rebase FETCH_HEAD
|
||||
done
|
||||
|
||||
@@ -810,7 +810,7 @@ jobs:
|
||||
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
|
||||
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
|
||||
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
|
||||
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
|
||||
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
|
||||
15
.github/workflows/qa-live-transports-convex.yml
vendored
15
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -52,6 +52,7 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL || 'openai/gpt-5.5' }}
|
||||
OPENCLAW_CI_OPENAI_FALLBACK_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_FALLBACK_MODEL || 'openai/gpt-5.4' }}
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
|
||||
@@ -288,7 +289,7 @@ jobs:
|
||||
--runtime-parity-tier live-only \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--runtime-pair openclaw,codex \
|
||||
--fast \
|
||||
--allow-failures \
|
||||
@@ -373,7 +374,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--profile "${INPUT_MATRIX_PROFILE}" \
|
||||
--fast
|
||||
)
|
||||
@@ -457,7 +458,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--profile "${{ matrix.profile }}" \
|
||||
--fast
|
||||
)
|
||||
@@ -555,7 +556,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -649,7 +650,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -746,7 +747,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -840,7 +841,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -249,6 +249,7 @@ extensions/qa-lab/web/dist/
|
||||
# Generated bundled plugin runtime dependency manifests
|
||||
extensions/**/.openclaw-runtime-deps.json
|
||||
extensions/**/.openclaw-runtime-deps-stamp.json
|
||||
extensions/diffs/assets/viewer-runtime.js
|
||||
extensions/diffs-language-pack/assets/viewer-runtime.js
|
||||
|
||||
# Output dir for scripts/run-opengrep.sh (local opengrep scans)
|
||||
|
||||
@@ -57,6 +57,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Runtime reads canonical config only. No silent compat for old/malformed config keys. If a config change invalidates existing files, add a matching `openclaw doctor --fix` migration. Core/auth config repairs live in core doctor; plugin-owned config repairs live in that plugin's doctor contract (`legacyConfigRules` / `normalizeCompatibilityConfig`).
|
||||
- CLI setup flows are public API when external docs, installers, or integrations can copy them. Changes to `openclaw onboard`, `openclaw configure`, their documented flags, non-interactive behavior, or generated config shape are compatibility-sensitive API contract changes; prefer additive flags/aliases, deprecation windows, and backward-preserving migrations over breaking existing snippets.
|
||||
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
|
||||
- Fix observed local failures with generic product rules; do not hardcode names, ids, log phrases, or user examples in prod code unless they are an explicit contract.
|
||||
- Tests may use observed examples, but prod literals need a short contract reason.
|
||||
|
||||
@@ -17,14 +17,18 @@ Docs: https://docs.openclaw.ai
|
||||
- Status: show active subagent details in status output.
|
||||
- Diffs: split the default language pack and expand default Diffs language coverage while keeping the host floor aligned. (#87370, #87372) Thanks @RomneyDa.
|
||||
- ClawHub: add plugin display names plus skill verification and trust surfaces. (#87354, #86699) Thanks @thewilloftheshadow and @Patrick-Erichsen.
|
||||
- iOS: refresh the dev app with Pro Command, Chat, Agents, and Settings tabs wired to gateway sessions, diagnostics, chat, and realtime Talk. (#87367) Thanks @Solvely-Colin.
|
||||
- Docs: clarify Codex computer-use setup, paste-token stdin auth setup, macOS gateway sleep troubleshooting, native Codex hook relay recovery, container model auth, install deployment cards, device-token admin gating, and backport targets. (#87313, #63050) Thanks @bdjben, @liaoandi, and @thewilloftheshadow.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
|
||||
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
|
||||
- Channels: thread canonical session keys into outbound hooks, preserve Matrix room-id case, keep fallback tool warnings mention-inert, retain delivered Slack final replies during late cleanup, continue iMessage polling after denied reactions, suppress duplicate native exec approvals, preserve Telegram SecretRef prompt config, suppress Discord recovered tool warnings, and block untrusted Teams service URLs. (#73706, #75670, #87366, #87451, #87334) Thanks @zeroaltitude, @lukeboyett, @xiaotian, and @eleqtrizit.
|
||||
- CLI/auth/doctor/providers: reject malformed numeric/timeout/subcommand-version inputs, wait for respawn child shutdown, bound Codex and GitHub Copilot OAuth/token requests, warm provider auth off the main thread, honor Codex response timeouts, bound local service startup, resolve GPT-5.5 without cached catalog, migrate legacy memory auto-provider config, rewrite non-canonical `api_key` auth profiles, and make doctor restart follow-ups actionable. (#87398, #86281, #87361) Thanks @Patrick-Erichsen, @samzong, @giodl73-repo, and @alkor2000.
|
||||
- Gateway/security/session state: expire browser tokens after auth rotation, scope assistant idempotency dedupe, drain probe client closes, avoid stale restart continuation reuse, preserve retry-after fallbacks, bound webchat image and artifact transcript scans, include seconds in inbound metadata timestamps, and evict current plugin-state namespaces at row caps.
|
||||
- File transfer: handle late tar stdin pipe errors after archive validation or unpacking has already settled.
|
||||
- Performance: trust install-record caches between reloads, prefer native JSON parsing, reuse unchanged tool-search catalogs, skip unchanged store serialization, add precomputed session patch writers, reduce store clone allocations, cache manifest model catalog rows and auto-enabled plugin config, and slim current metadata identity caches.
|
||||
- Docker/release/QA: package runtime workspace templates, stream cross-OS served artifacts, preserve sparse Crabbox run artifacts, bound OpenClaw instance logs, plugin gauntlet relay logs, MCP channel buffers, kitchen-sink scans, agent-turn assertions, and release scenario logs, and keep release/google live guards current.
|
||||
|
||||
@@ -55,7 +59,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/performance: cache plugin metadata fingerprints and stable plugin index fingerprints, borrow read-only session metadata safely, keep the active session working store hot, keep status on a bounded fast path, and preserve model auth profile suffixes. (#86439)
|
||||
- Package/install/release: align npm package exclusions and inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, cap tsdown heap in containers, bound install/release smoke waits, and harden post-publish verification.
|
||||
- Codex/Auth: bound ChatGPT OAuth token exchange and refresh requests, and honor cancellation across Codex and Anthropic OAuth login flows.
|
||||
- QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, plugin gauntlet, and beta-smoke runs instead of false-greening.
|
||||
- QA/E2E/CI: bound Telegram, kitchen-sink, Open WebUI, ClawHub, MCP, Discord, realtime, labeler, and GitHub API waits; fail empty explicit test, live-media, gateway CPU, startup benchmark, plugin gauntlet, and beta-smoke runs instead of false-greening.
|
||||
- Agents/Codex: keep spawned agent bootstrap files rooted in the agent workspace while running task commands, transcripts, and compaction from the requested cwd. (#87218) Thanks @mbelinky.
|
||||
|
||||
## 2026.5.26
|
||||
|
||||
146
appcast.xml
146
appcast.xml
@@ -2,6 +2,53 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.5.27</title>
|
||||
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
|
||||
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
|
||||
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
|
||||
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
|
||||
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
|
||||
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
|
||||
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
|
||||
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
|
||||
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
|
||||
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
|
||||
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
|
||||
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
|
||||
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
|
||||
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
|
||||
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
|
||||
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
|
||||
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
|
||||
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.26</title>
|
||||
<pubDate>Wed, 27 May 2026 12:24:26 +0000</pubDate>
|
||||
@@ -490,104 +537,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.22/OpenClaw-2026.5.22.zip" length="54409357" type="application/octet-stream" sparkle:edSignature="am1mwLOmUHor9QuQWtxSsKoBOCySUBo4fB+0Qdcrz0E3wf6ESIMTfOC0k+dKJSh9gtLZw5jzpWVqTBzEdU36Aw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.20</title>
|
||||
<pubDate>Thu, 21 May 2026 21:19:52 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052090</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.20</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.20</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Exec approvals: remove the old <code>cat SKILL.md && printf ... && <skill-wrapper></code> allowlist compatibility path so skill files must be loaded with the read tool and only the real skill executable is auto-allowed.</li>
|
||||
<li>Discord: let voice sessions follow configured Discord users into voice channels, with allowed-channel checks, multi-user handoff, bounded reconciliation, and DAVE recovery preservation. (#84264) Thanks @fuller-stack-dev.</li>
|
||||
<li>Discord/voice: include bounded <code>IDENTITY.md</code>, <code>USER.md</code>, and <code>SOUL.md</code> profile context in realtime voice session instructions by default, with <code>voice.realtime.bootstrapContextFiles: []</code> available to disable it. (#84499) Thanks @fuller-stack-dev.</li>
|
||||
<li>Dependencies: bump the bundled Codex harness to <code>@openai/codex</code> <code>0.132.0</code> and refresh the app-server model-list docs for the new catalog.</li>
|
||||
<li>CLI/policy: add the bundled Policy plugin for policy-backed channel conformance checks, doctor lint findings, and opt-in workspace repair. (#80407) Thanks @giodl73-repo.</li>
|
||||
<li>Agents/config: allow <code>agents.list[].experimental.localModelLean</code> so lean local-model mode can be enabled for one configured agent instead of globally.</li>
|
||||
<li>Providers/xAI: add device-code OAuth login so remote and headless setups can authorize xAI without a localhost browser callback. (#84005) Thanks @fuller-stack-dev.</li>
|
||||
<li>Providers/OpenRouter: honor provider-level <code>params.provider</code> routing policy for OpenRouter requests, with model and agent params overriding the defaults. Thanks @amknight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI/tasks: include stale-running task maintenance decisions in <code>openclaw tasks maintenance --json</code> so retained and reconcile candidates explain backing-session, cron, CLI, and wedged-subagent state. (#84691) Thanks @efpiva.</li>
|
||||
<li>Codex app-server: keep system-prompt reports working when bootstrap hooks provide workspace files with only a path and content, so hook-supplied SOUL/IDENTITY/TOOLS/USER context still reports injected characters correctly. (#84736) Thanks @JARVIS-Glasses.</li>
|
||||
<li>Providers/MiniMax music: stop advertising <code>durationSeconds</code> control and remove prompt-injected duration hints, so <code>music_generate</code> reports MiniMax duration as an unsupported override instead of suggesting MiniMax can enforce track length. Fixes #84508. Thanks @neeravmakwana.</li>
|
||||
<li>Doctor: warn when sandbox tool policy hides configured MCP server tools before provider requests. (#84699) Thanks @nxmxbbd.</li>
|
||||
<li>WhatsApp: update Baileys to <code>7.0.0-rc12</code>.</li>
|
||||
<li>Build: suppress per-locale <code>rolldown-plugin-dts:fake-js</code> CommonJS dts warnings emitted while bundling the intentionally-inlined <code>zod/v4/locales/*.d.cts</code> files, so <code>pnpm build</code> output stays readable after the 0.25.1 plugin bump. Thanks @romneyda.</li>
|
||||
<li>CLI/nodes: route lazy plugin-registration logs to stderr for JSON-mode <code>openclaw nodes</code> commands so stdout stays parseable. (#84684) Thanks @TurboTheTurtle.</li>
|
||||
<li>Approvals: route manual <code>/approve</code> decisions through the trusted approval runtime so active exec and plugin approvals no longer look unknown or expired.</li>
|
||||
<li>Mac app: update the About settings copyright year to 2026. (#84385) Thanks @pejmanjohn.</li>
|
||||
<li>Dependencies: update <code>@openclaw/fs-safe</code> to <code>0.2.7</code> so OpenClaw's default Python-helper-off policy keeps best-effort Node write fallbacks for private stores, secret writes, run logs, and media attachments on Linux/macOS.</li>
|
||||
<li>Infra/secrets: restore the fail-closed contract for <code>tryReadSecretFileSync</code> so credential loaders that pass <code>rejectSymlink: true</code> (Telegram, LINE, Zalo, IRC, Nextcloud Talk tokens) refuse symlinked credential files instead of silently accepting them, and the infra-state CI shard's secret-file symlink test passes again. Thanks @romneyda.</li>
|
||||
<li>Browser: honor the configured image sanitization limit for screenshots and labeled snapshots so browser-captured images follow the same resize policy as other image results. (#84595)</li>
|
||||
<li>Doctor: remove unrecognized <code>models.providers.*.models[*].compat.thinkingFormat</code> values during <code>doctor --fix</code> so stale provider model config can validate after upgrade. Fixes #77803.</li>
|
||||
<li>Doctor: warn when <code>openclaw.json</code> stores plaintext secret-bearing config fields, including model provider API keys and sensitive provider headers. (#84718) Thanks @lukaIvanic.</li>
|
||||
<li>Status: show the configured default, session-selected model, reason, clear hint, and docs link when a session remains pinned to a model that differs from <code>agents.defaults.model.primary</code>.</li>
|
||||
<li>WebChat: clear stale typing indicators when session change events mark the active chat run complete.</li>
|
||||
<li>Mac app: keep local packaging signed with a stable app identity for permission testing and fix Control UI production builds under current Vite/Highlight.js exports.</li>
|
||||
<li>macOS app: update the embedded Peekaboo bridge to 3.2.1 so OpenClaw-hosted UI automation works with current Peekaboo CLI capture flows.</li>
|
||||
<li>Cron: deliver preferred final assistant output for successful scheduled runs when trailing plain tool warnings remain in diagnostics instead of marking the run failed.</li>
|
||||
<li>fix(mattermost): fail closed on missing channel type [AI]. (#84091) Thanks @pgondhi987.</li>
|
||||
<li>Recheck rebuilt system.run argv [AI]. (#84090) Thanks @pgondhi987.</li>
|
||||
<li>CLI: keep the private QA subcommand out of exported command descriptors unless <code>OPENCLAW_ENABLE_PRIVATE_QA_CLI=1</code>, so root help and subcommand markers match runtime registration. (#84519)</li>
|
||||
<li>CLI/cron: bound <code>openclaw cron show</code> job lookup pagination so non-advancing or unbounded <code>cron.list</code> responses fail instead of hanging the command. Fixes #83856. (#83989)</li>
|
||||
<li>Agents/messages: stop message-tool-only turns after a successful source-channel <code>message</code> send while keeping transcript mirrors under the session write lock. (#84289)</li>
|
||||
<li>Agents: filter silent heartbeat response-tool transcript artifacts out of embedded context snapshots so later user turns are not polluted by heartbeat no-op messages. (#83477) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/OpenAI: log repeated strict tool-schema downgrade diagnostics once per provider/model/tool signature, reducing duplicate debug noise while preserving <code>strict=false</code> fallback behavior. Fixes #82930. (#82933) Thanks @galiniliev.</li>
|
||||
<li>Agents/code mode: spell out the <code>exec</code> tool's JavaScript/TypeScript, no Node module, and catalog-bridge constraints in model-visible schema text so agents can use enabled tools without trial-and-error. (#84269) Thanks @Kaspre.</li>
|
||||
<li>Codex: give <code>image_generate</code> dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.</li>
|
||||
<li>Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.</li>
|
||||
<li>CLI/message: include a stable top-level <code>messageId</code> in <code>openclaw message --json</code> output when channel sends return one. (#84191) Thanks @100menotu001.</li>
|
||||
<li>Cron: preserve legacy top-level array <code>jobs.json</code> stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.</li>
|
||||
<li>Gateway/agents: use an agent's <code>identity.name</code> in Gateway agent summaries when <code>agents.list[].name</code> is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.</li>
|
||||
<li>Channels/replies: keep normal <code>/verbose</code> failed-tool progress compact in message-tool replies and prevent late text-only tool output from appearing after the final answer. (#84303) Thanks @VACInc.</li>
|
||||
<li>Plugins/hooks: apply a default 30-second timeout to <code>before_compaction</code> and <code>after_compaction</code> hooks so a hung plugin handler no longer blocks compaction completion. (#84153)</li>
|
||||
<li>Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.</li>
|
||||
<li>Twitch: add a test-only client-manager registry reset helper so non-isolated Twitch tests can clear cached managers between cases. Fixes #83887. (#84244) Thanks @hclsys.</li>
|
||||
<li>Cron: run main-session scheduled work on a cron-owned wake lane while preserving reply delivery context, so background cron turns no longer block human main-session chat. Fixes #82766. (#82767) Thanks @galiniliev.</li>
|
||||
<li>Cron: use structured embedded-run denial metadata for isolated scheduled tasks so blocked exec requests fail the job without treating ordinary assistant prose as a denial. (#84067) Thanks @abnershang.</li>
|
||||
<li>Cron: keep recovered tool warnings diagnostic for successful scheduled runs so final cron output is delivered instead of being replaced by a post-processing warning. (#84045) Thanks @abnershang.</li>
|
||||
<li>Plugins/perf: thread explicit plugin discovery results through <code>loadBundledCapabilityRuntimeRegistry</code>, <code>resolveBundledPluginSources</code>, and <code>listChannelCatalogEntries</code> so callers that already hold a discovery result skip redundant filesystem walks. Thanks @SebTardif.</li>
|
||||
<li>harden update restart script creation [AI]. (#84088) Thanks @pgondhi987.</li>
|
||||
<li>Docker: keep the bundled Codex plugin in official release image keep lists so the default OpenAI agent harness remains available after Docker pruning. Fixes #83613. (#83626) Thanks @YuanHanzhong.</li>
|
||||
<li>CLI/channels: preserve the first line of <code>openclaw channels logs</code> output when the rolling tail window starts exactly on a line boundary, mirroring the already-fixed <code>readLogSlice</code> behavior in <code>src/logging/log-tail.ts</code>.</li>
|
||||
<li>Control UI: treat terminal session status as authoritative over stale active-run flags so completed terminal runs stop showing abort/live UI. (#84057)</li>
|
||||
<li>CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.</li>
|
||||
<li>Matrix/config: accept <code>messages.queue.byChannel.matrix</code> queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.</li>
|
||||
<li>CLI: format <code>openclaw acp client</code> failures through the shared error formatter so object-shaped errors stay readable instead of printing <code>[object Object]</code>. Fixes #83904. (#84080)</li>
|
||||
<li>Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when <code>/api/show</code> omits capabilities. (#84055) Thanks @dutifulbob.</li>
|
||||
<li>Installer/Windows: launch <code>install.ps1</code> onboarding as an attached child process so fresh native Windows installs do not freeze visibly at <code>Starting setup...</code> or corrupt the wizard's terminal rendering.</li>
|
||||
<li>CLI/update: keep restart health checks working across one-version CLI/Gateway protocol skew and use the managed Gateway service Node for all follow-up commands even when the package root is unchanged, so <code>openclaw update</code> no longer silently switches the gateway to a different Node binary when multiple Node installations are present. Thanks @amknight.</li>
|
||||
<li>CLI/gateway: include the running Gateway version in <code>gateway status</code> JSON output, preserving existing server metadata while falling back to status RPC data for read probes. Fixes #56222. Thanks @galiniliev.</li>
|
||||
<li>Memory/search: close local embedding providers when active-memory searches time out so pending local model loads and embedding contexts are aborted and released. (#83858) Thanks @brokemac79.</li>
|
||||
<li>CLI/nodes: request pending node surface approval scopes before <code>openclaw nodes approve</code> so exec-capable node approval can use admin-scoped Gateway credentials instead of failing with <code>missing scope: operator.admin</code>. (#84392) Thanks @joshavant.</li>
|
||||
<li>Gateway: reject slow node event sends before outbound buffers grow unbounded and log the rejected payload diagnostic. (#84387) Thanks @samzong.</li>
|
||||
<li>Agents: include bounded trajectory queued-writer diagnostics in <code>pi-trajectory-flush</code> timeout warnings so flush stalls show pending writes, queued bytes, and append state. Fixes #82961. (#82962) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: recover stale completion announces by retrying unsupported transcript-wait wakes without transcript waiting and forcing a message-tool handoff when the requester run is already stale. Fixes #83699. (#83700) Thanks @galiniliev.</li>
|
||||
<li>Agents/subagents: constrain wildcard subagent target allowlists to configured agents while preserving explicitly listed compatibility targets. Fixes #84040. (#84357) Thanks @joshavant.</li>
|
||||
<li>Providers/Anthropic: route Anthropic model refs selected with Claude CLI auth through the Claude CLI runtime so shorthand refs such as <code>anthropic/opus-4.7</code> no longer fall back to embedded Anthropic billing. Fixes #84222. (#84374) Thanks @joshavant.</li>
|
||||
<li>Agents: honor explicit <code>models.providers.<id>.timeoutSeconds</code> values above the default idle watchdog for cloud and self-hosted providers, so long first-token waits no longer fall back at ~120s when the provider timeout is higher. (#83979) Thanks @yujiawei.</li>
|
||||
<li>Agents/Codex: keep encrypted Responses reasoning replay provenance-bound so stale mirrored Codex transcripts drop invalid encrypted content before request assembly while preserving matching same-session replay. Fixes #83836. (#84367) Thanks @joshavant.</li>
|
||||
<li>Agents/subagents: skip stale embedded-run wake probes for dormant completion requesters, so late subagent completions go straight to requester-agent/direct handoff instead of producing <code>reason=no_active_run</code> queue noise. (#82964) Thanks @galiniliev.</li>
|
||||
<li>CLI: retry config snapshot reads after a transient failure so one rejected read no longer poisons later commands in the same process. (#83931) Thanks @honor2030.</li>
|
||||
<li>Media: decode URL path basenames before using them as remote media fallback filenames, so files like <code>My%20Report.pdf</code> are surfaced as <code>My Report.pdf</code>. Fixes #84050. (#84052) Thanks @jbetala7.</li>
|
||||
<li>WhatsApp: clarify inbound group diagnostics so observed but unregistered groups point to <code>channels.whatsapp.groups</code> without changing routing or sender authorization. (#83846) Thanks @neeravmakwana.</li>
|
||||
<li>WhatsApp: drain pending outbound deliveries on a 30s periodic timer in addition to the reconnect handler, so messages enqueued while the provider is already connected no longer wait for the next reconnect to send. (#79083) Thanks @Oviemudiaga.</li>
|
||||
<li>CLI/TUI: include gateway plugin slash commands in TUI autocomplete, so connected sessions can suggest plugin-owned commands exposed by the running Gateway. (#83640) Thanks @se7en-agent.</li>
|
||||
<li>Gateway/mobile: restore QR setup-code handoff of bounded operator tokens for iOS and Android onboarding while keeping admin and pairing scopes out of bootstrap. (#83684) Thanks @ngutman.</li>
|
||||
<li>iOS: repair Release archive compilation for the TestFlight build. (#84255) Thanks @ngutman.</li>
|
||||
<li>Agents/compaction: bound plugin-owned CLI transcript compaction with the host safety timeout so a hung context engine can no longer stall post-turn cleanup. (#84083) Thanks @100yenadmin.</li>
|
||||
<li>Control UI/usage: truncate long context skill, tool, and file names in the usage panel while keeping the full name available on hover. (#42197) Thanks @Rain120.</li>
|
||||
<li>Codex: respect explicit <code>models auth order set</code> and <code>config.auth.order</code> precedence over stale <code>lastGood</code> in <code>/codex account</code>, and show <code>no working credential</code> when every explicit-order profile is ineligible instead of marking a lower-ranked profile as active. Fixes #84386. (#84412) Thanks @openperf.</li>
|
||||
<li>Agents: honor <code>messages.suppressToolErrors</code> for mutating tool failures so configured chat surfaces do not receive separate warning payloads. (#81561) Thanks @moeedahmed.</li>
|
||||
<li>Agents/fallback: surface billing guidance for mixed rate-limit plus billing fallback exhaustion instead of generic failure copy. Fixes #79396. (#79489) Thanks @aayushprsingh.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.20/OpenClaw-2026.5.20.zip" length="54396392" type="application/octet-stream" sparkle:edSignature="Ufz+twYjgj5NDg29tG3Ttx/JNyT3/a3EKLciBGvsa38C6Dwqp4yFYC5jSBiSlubwBXhrq8OQDMgavMKtSsclBQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -44,7 +44,7 @@ internal fun isLoopbackGatewayHost(
|
||||
return isMappedIpv4 && address[12] == 127.toByte()
|
||||
}
|
||||
|
||||
internal fun isPrivateLanGatewayHost(
|
||||
internal fun isLocalCleartextGatewayHost(
|
||||
rawHost: String?,
|
||||
allowEmulatorBridgeAlias: Boolean = isAndroidEmulatorRuntime(),
|
||||
): Boolean {
|
||||
|
||||
@@ -632,7 +632,7 @@ class GatewaySession(
|
||||
|
||||
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
|
||||
if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false
|
||||
if (isLoopbackGatewayHost(endpoint.host)) return true
|
||||
if (isLocalCleartextGatewayHost(endpoint.host)) return true
|
||||
return tls != null
|
||||
}
|
||||
|
||||
@@ -1212,9 +1212,7 @@ class GatewaySession(
|
||||
endpoint: GatewayEndpoint,
|
||||
tls: GatewayTlsParams?,
|
||||
): Boolean {
|
||||
if (isLoopbackGatewayHost(endpoint.host)) {
|
||||
return true
|
||||
}
|
||||
if (isLocalCleartextGatewayHost(endpoint.host)) return true
|
||||
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import ai.openclaw.app.gateway.GatewayClientInfo
|
||||
import ai.openclaw.app.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayTlsParams
|
||||
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import android.os.Build
|
||||
|
||||
@@ -35,7 +36,12 @@ class ConnectionManager(
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
|
||||
val cleartextAllowedHost =
|
||||
if (isManual) {
|
||||
isLocalCleartextGatewayHost(endpoint.host)
|
||||
} else {
|
||||
isLoopbackGatewayHost(endpoint.host)
|
||||
}
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled && cleartextAllowedHost) return null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
@@ -56,9 +56,9 @@ internal data class GatewayScannedSetupCodeResult(
|
||||
|
||||
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
|
||||
private const val remoteGatewaySecurityRule =
|
||||
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator."
|
||||
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, the Android emulator, and private LAN IPs."
|
||||
private const val remoteGatewaySecurityFix =
|
||||
"Use localhost/the Android emulator, or enable Tailscale Serve / expose a wss:// gateway URL."
|
||||
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
|
||||
|
||||
internal fun resolveGatewayConnectConfig(
|
||||
useSetupCode: Boolean,
|
||||
@@ -147,7 +147,7 @@ internal fun parseGatewayEndpointResult(rawInput: String): GatewayEndpointParseR
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INVALID_URL)
|
||||
}
|
||||
val tls = scheme == "wss" || scheme == "https"
|
||||
if (!tls && !isLoopbackGatewayHost(host)) {
|
||||
if (!tls && !isLocalCleartextGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort = if (tls) 443 else 18789
|
||||
|
||||
@@ -51,7 +51,7 @@ internal fun buildGatewayDiagnosticsReport(
|
||||
Please:
|
||||
- pick one route only: same machine, same LAN, Tailscale, or public URL
|
||||
- classify this as pairing/auth, TLS trust, wrong advertised route, wrong address/port, or gateway down
|
||||
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; ws:// is loopback-only
|
||||
- remember: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, the Android emulator, and private LAN IPs
|
||||
- quote the exact app status/error below
|
||||
- tell me whether `openclaw devices list` should show a pending pairing request
|
||||
- if more signal is needed, ask for `openclaw qr --json`, `openclaw devices list`, and `openclaw nodes status`
|
||||
|
||||
@@ -4,8 +4,8 @@ import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import ai.openclaw.app.VoiceWakeMode
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
|
||||
import ai.openclaw.app.protocol.OpenClawCallLogCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.app.protocol.OpenClawCapability
|
||||
@@ -109,7 +109,7 @@ class ConnectionManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_manualPrivateLanForcesTlsWhenToggleIsOff() {
|
||||
fun resolveTlsParamsForEndpoint_manualPrivateLanRespectsManualTlsToggle() {
|
||||
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
|
||||
|
||||
val params =
|
||||
@@ -119,9 +119,21 @@ class ConnectionManagerTest {
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals(true, params?.required)
|
||||
assertNull(params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
assertNull(params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_manualPrivateLanCleartextCanOverrideStoredPin() {
|
||||
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
|
||||
|
||||
val params =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = "pinned",
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertNull(params)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -245,11 +257,11 @@ class ConnectionManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isPrivateLanGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() {
|
||||
assertTrue(isPrivateLanGatewayHost("192.168.1.20"))
|
||||
assertFalse(isPrivateLanGatewayHost("gateway.local"))
|
||||
assertFalse(isPrivateLanGatewayHost("100.64.0.9"))
|
||||
assertFalse(isPrivateLanGatewayHost("gateway.tailnet.ts.net"))
|
||||
fun isLocalCleartextGatewayHost_acceptsLanIpsButRejectsMdnsAndTailnetHosts() {
|
||||
assertTrue(isLocalCleartextGatewayHost("192.168.1.20"))
|
||||
assertFalse(isLocalCleartextGatewayHost("gateway.local"))
|
||||
assertFalse(isLocalCleartextGatewayHost("100.64.0.9"))
|
||||
assertFalse(isLocalCleartextGatewayHost("gateway.tailnet.ts.net"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -99,9 +99,18 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointRejectsPrivateLanCleartextWsUrls() {
|
||||
fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() {
|
||||
val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789")
|
||||
assertNull(parsed)
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "192.168.1.20",
|
||||
port = 18789,
|
||||
tls = false,
|
||||
displayUrl = "http://192.168.1.20:18789",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -146,9 +155,13 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() {
|
||||
fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() {
|
||||
val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]")
|
||||
assertNull(parsed)
|
||||
|
||||
assertEquals("fe80::1%25eth0", parsed?.host)
|
||||
assertEquals(18789, parsed?.port)
|
||||
assertEquals(false, parsed?.tls)
|
||||
assertEquals("http://[fe80::1%25eth0]:18789", parsed?.displayUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -249,6 +262,16 @@ class GatewayConfigResolverTest {
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
|
||||
val setupCode =
|
||||
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
|
||||
|
||||
val resolved = resolveScannedSetupCode(setupCode)
|
||||
|
||||
assertEquals(setupCode, resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeResultFlagsInsecureRemoteGateway() {
|
||||
val setupCode =
|
||||
@@ -277,10 +300,19 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
|
||||
fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() {
|
||||
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
|
||||
assertNull(parsed.config)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "192.168.1.20",
|
||||
port = 18789,
|
||||
tls = false,
|
||||
displayUrl = "http://192.168.1.20:18789",
|
||||
),
|
||||
parsed.config,
|
||||
)
|
||||
assertNull(parsed.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -421,7 +453,7 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGatewayConnectConfigRejectsPrivateLanManualCleartextEndpoint() {
|
||||
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
|
||||
val resolved =
|
||||
resolveGatewayConnectConfig(
|
||||
useSetupCode = false,
|
||||
@@ -437,7 +469,9 @@ class GatewayConfigResolverTest {
|
||||
fallbackPassword = "",
|
||||
)
|
||||
|
||||
assertNull(resolved)
|
||||
assertEquals("192.168.31.100", resolved?.host)
|
||||
assertEquals(18789, resolved?.port)
|
||||
assertEquals(false, resolved?.tls)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
12
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/Contents.json
vendored
Normal file
12
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "openclaw-icon.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/openclaw-icon.png
vendored
Normal file
BIN
apps/ios/Sources/Assets.xcassets/OpenClawIcon.imageset/openclaw-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
@@ -1,47 +0,0 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct ChatSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: OpenClawChatViewModel
|
||||
private let userAccent: Color?
|
||||
private let agentName: String?
|
||||
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) {
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
self._viewModel = State(
|
||||
initialValue: OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: transport))
|
||||
self.userAccent = userAccent
|
||||
self.agentName = agentName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
OpenClawChatView(
|
||||
viewModel: self.viewModel,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: self.userAccent)
|
||||
.navigationTitle(self.chatTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chatTitle: String {
|
||||
let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "Chat" }
|
||||
return "Chat (\(trimmed))"
|
||||
}
|
||||
}
|
||||
@@ -6,30 +6,162 @@ import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
static let defaultChatSendTimeoutMs = 30000
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
private struct CreateSessionParams: Codable {
|
||||
var key: String
|
||||
var label: String?
|
||||
var parentSessionKey: String?
|
||||
}
|
||||
|
||||
private struct RunParams: Codable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
|
||||
private struct ListSessionsParams: Codable {
|
||||
var includeGlobal: Bool
|
||||
var includeUnknown: Bool
|
||||
var limit: Int?
|
||||
}
|
||||
|
||||
private struct SessionKeyParams: Codable {
|
||||
var key: String
|
||||
}
|
||||
|
||||
private struct ChatSendParams: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
var thinking: String
|
||||
var attachments: [OpenClawChatAttachmentPayload]?
|
||||
var timeoutMs: Int
|
||||
var idempotencyKey: String
|
||||
}
|
||||
|
||||
private struct AgentWaitParams: Codable {
|
||||
var runId: String
|
||||
var timeoutMs: Int
|
||||
}
|
||||
|
||||
private struct AgentWaitResponse: Codable {
|
||||
var runId: String?
|
||||
var status: String?
|
||||
var error: String?
|
||||
}
|
||||
|
||||
struct AgentWaitCompletion: Equatable {
|
||||
var runId: String
|
||||
var status: String
|
||||
var completed: Bool
|
||||
}
|
||||
|
||||
static func isAgentWaitCompletionStatus(_ status: String) -> Bool {
|
||||
switch status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "ok", "completed", "success", "succeeded":
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
init(gateway: GatewayNodeSession) {
|
||||
self.gateway = gateway
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
static func agentWaitRequestTimeoutSeconds(timeoutMs: Int) -> Int {
|
||||
max(1, Int(ceil(Double(timeoutMs) / 1000.0)) + 5)
|
||||
}
|
||||
|
||||
static func makeListSessionsParamsJSON(limit: Int?) throws -> String {
|
||||
try self.encodeParams(ListSessionsParams(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||
}
|
||||
|
||||
static func makeChatSendParamsJSON(
|
||||
sessionKey: String,
|
||||
message: String,
|
||||
thinking: String,
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) throws -> String
|
||||
{
|
||||
let params = ChatSendParams(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
timeoutMs: self.defaultChatSendTimeoutMs,
|
||||
idempotencyKey: idempotencyKey)
|
||||
return try self.encodeParams(params)
|
||||
}
|
||||
|
||||
static func decodeAgentWaitCompletion(_ data: Data, fallbackRunId: String) throws -> AgentWaitCompletion {
|
||||
let decoded = try JSONDecoder().decode(AgentWaitResponse.self, from: data)
|
||||
let status = (decoded.status ?? "unknown").lowercased()
|
||||
return AgentWaitCompletion(
|
||||
runId: decoded.runId ?? fallbackRunId,
|
||||
status: status,
|
||||
completed: self.isAgentWaitCompletionStatus(status))
|
||||
}
|
||||
|
||||
private static func makeCreateSessionParamsJSON(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) throws -> String
|
||||
{
|
||||
let params = CreateSessionParams(
|
||||
key: key,
|
||||
label: label,
|
||||
parentSessionKey: parentSessionKey)
|
||||
return try self.encodeParams(params)
|
||||
}
|
||||
|
||||
private static func makeRunParamsJSON(sessionKey: String, runId: String) throws -> String {
|
||||
try self.encodeParams(RunParams(sessionKey: sessionKey, runId: runId))
|
||||
}
|
||||
|
||||
private static func makeSessionKeyParamsJSON(_ sessionKey: String) throws -> String {
|
||||
try self.encodeParams(SessionKeyParams(key: sessionKey))
|
||||
}
|
||||
|
||||
private static func makeHistoryParamsJSON(sessionKey: String) throws -> String {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
return try self.encodeParams(Params(sessionKey: sessionKey))
|
||||
}
|
||||
|
||||
private static func makeAgentWaitParamsJSON(runId: String, timeoutMs: Int) throws -> String {
|
||||
try self.encodeParams(AgentWaitParams(runId: runId, timeoutMs: timeoutMs))
|
||||
}
|
||||
|
||||
private static func encodeParams(_ params: some Encodable) throws -> String {
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(bytes: data, encoding: .utf8) else {
|
||||
throw EncodingError.invalidValue(
|
||||
params,
|
||||
EncodingError.Context(codingPath: [], debugDescription: "Encoded gateway params were not UTF-8"))
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
return json
|
||||
}
|
||||
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
let json = try Self.makeCreateSessionParamsJSON(
|
||||
key: key,
|
||||
label: label,
|
||||
parentSessionKey: parentSessionKey)
|
||||
let res = try await self.gateway.request(method: "sessions.create", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatCreateSessionResponse.self, from: res)
|
||||
}
|
||||
|
||||
func abortRun(sessionKey: String, runId: String) async throws {
|
||||
let json = try Self.makeRunParamsJSON(sessionKey: sessionKey, runId: runId)
|
||||
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
struct Params: Codable {
|
||||
var includeGlobal: Bool
|
||||
var includeUnknown: Bool
|
||||
var limit: Int?
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeListSessionsParamsJSON(limit: limit)
|
||||
let res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
|
||||
}
|
||||
@@ -40,23 +172,17 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
|
||||
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
|
||||
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
|
||||
}
|
||||
@@ -73,35 +199,52 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
var thinking: String
|
||||
var attachments: [OpenClawChatAttachmentPayload]?
|
||||
var timeoutMs: Int
|
||||
var idempotencyKey: String
|
||||
}
|
||||
|
||||
let params = Params(
|
||||
GatewayDiagnostics.log(startLogMessage)
|
||||
let json = try Self.makeChatSendParamsJSON(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
timeoutMs: 30000,
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
do {
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send ok runId=\(decoded.runId) status=\(decoded.status)")
|
||||
return decoded
|
||||
} catch {
|
||||
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||
GatewayDiagnostics.log("chat.send failed error=\(error.localizedDescription)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRunCompletion(runId rawRunId: String, timeoutMs: Int) async -> Bool {
|
||||
let runId = rawRunId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !runId.isEmpty else { return false }
|
||||
|
||||
do {
|
||||
let json = try Self.makeAgentWaitParamsJSON(runId: runId, timeoutMs: timeoutMs)
|
||||
let requestTimeoutSeconds = Self.agentWaitRequestTimeoutSeconds(timeoutMs: timeoutMs)
|
||||
GatewayDiagnostics.log("agent.wait start runId=\(runId)")
|
||||
let res = try await self.gateway.request(
|
||||
method: "agent.wait",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: requestTimeoutSeconds)
|
||||
let completion = try Self.decodeAgentWaitCompletion(res, fallbackRunId: runId)
|
||||
GatewayDiagnostics.log("agent.wait completed runId=\(completion.runId) status=\(completion.status)")
|
||||
if !completion.completed {
|
||||
Self.logger.warning(
|
||||
"agent.wait status \(completion.status, privacy: .public) runId=\(runId, privacy: .public)")
|
||||
}
|
||||
return completion.completed
|
||||
} catch {
|
||||
Self.logger.warning("agent.wait failed \(error.localizedDescription, privacy: .public)")
|
||||
GatewayDiagnostics.log("agent.wait failed runId=\(runId) error=\(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
|
||||
let res = try await self.gateway.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
|
||||
|
||||
690
apps/ios/Sources/Design/AgentProDreamingDestination.swift
Normal file
690
apps/ios/Sources/Design/AgentProDreamingDestination.swift
Normal file
@@ -0,0 +1,690 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProDreamingDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let overviewLoading: Bool
|
||||
let dreamingValue: String
|
||||
let dreamingDetail: String
|
||||
let dreamingColor: Color
|
||||
let refresh: () async -> Void
|
||||
@State private var selectedDreamDiaryDayID: String?
|
||||
@State private var dreamActionBusy: DreamAction?
|
||||
@State private var dreamActionStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
value: self.dreamingValue,
|
||||
detail: self.dreamingDetail,
|
||||
color: self.dreamingColor)
|
||||
self.dreamingTotalsCard
|
||||
self.dreamingActionsCard
|
||||
self.dreamDiaryCard
|
||||
self.dreamingEntriesList(
|
||||
title: "Promoted Entries",
|
||||
entries: self.overview?.dreaming?.promotedEntries ?? [],
|
||||
emptyTitle: "No promoted entries",
|
||||
emptyDetail: "Dreaming has not promoted durable memory entries yet.")
|
||||
self.dreamingEntriesList(
|
||||
title: "Signal Entries",
|
||||
entries: self.overview?.dreaming?.signalEntries ?? [],
|
||||
emptyTitle: "No signal entries",
|
||||
emptyDetail: "No recent recall, daily, grounded, or phase signals were reported.")
|
||||
self.dreamingEntriesList(
|
||||
title: "Short-Term Recall",
|
||||
entries: self.overview?.dreaming?.shortTermEntries ?? [],
|
||||
emptyTitle: "No short-term entries",
|
||||
emptyDetail: "The short-term dreaming store is empty.")
|
||||
self.dreamingPhasesCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refresh()
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Dreaming")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private enum DreamAction: String, CaseIterable, Identifiable {
|
||||
case backfill
|
||||
case repair
|
||||
case dedupe
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .backfill: "Backfill"
|
||||
case .repair: "Repair"
|
||||
case .dedupe: "Dedupe"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .backfill: "book.pages"
|
||||
case .repair: "wrench.and.screwdriver"
|
||||
case .dedupe: "square.stack.3d.down.right"
|
||||
}
|
||||
}
|
||||
|
||||
var method: String {
|
||||
switch self {
|
||||
case .backfill: "doctor.memory.backfillDreamDiary"
|
||||
case .repair: "doctor.memory.repairDreamingArtifacts"
|
||||
case .dedupe: "doctor.memory.dedupeDreamDiary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamingTotalsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Memory State")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: self.dreamingValue, color: self.dreamingColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(
|
||||
label: "Short-term",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.shortTermCount ?? 0))
|
||||
self.detailMetric(
|
||||
label: "Signals",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.totalSignalCount ?? 0))
|
||||
self.detailMetric(
|
||||
label: "Promoted",
|
||||
value: Self.compactNumber(self.overview?.dreaming?.promotedToday ?? 0))
|
||||
}
|
||||
if let storeError = self.normalized(self.overview?.dreaming?.storeError) {
|
||||
Text(storeError)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamingActionsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Maintenance")
|
||||
.font(.headline)
|
||||
Text("Refresh reads live state. Maintenance actions update the gateway diary/artifacts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.refresh() }
|
||||
} label: {
|
||||
Image(systemName: self.overviewLoading ? "hourglass" : "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.overviewLoading)
|
||||
.accessibilityLabel("Refresh dreaming")
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
ForEach(DreamAction.allCases) { action in
|
||||
Button {
|
||||
Task { await self.runDreamAction(action) }
|
||||
} label: {
|
||||
Label(action.title, systemImage: self.dreamActionBusy == action ? "hourglass" : action.icon)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(!self.gatewayConnected || self.dreamActionBusy != nil)
|
||||
}
|
||||
}
|
||||
|
||||
if let dreamActionStatusText {
|
||||
Text(dreamActionStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var dreamDiaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Dream Diary")
|
||||
ProCard(padding: 0) {
|
||||
if let diary = self.overview?.dreamDiary {
|
||||
if diary.found, let content = self.normalizedMultiline(diary.content) {
|
||||
let days = Self.dreamDiaryDays(from: content)
|
||||
let selectedDay = self.selectedDreamDiaryDay(from: days)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ProIconBadge(systemName: "book.pages", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(diary.path)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.dreamDiaryUpdatedLabel(diary))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if !days.isEmpty {
|
||||
self.dreamDiaryDayMenu(days: days, selectedDay: selectedDay)
|
||||
}
|
||||
}
|
||||
if let selectedDay {
|
||||
self.dreamDiaryDayView(selectedDay)
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "calendar.badge.exclamationmark",
|
||||
title: "No day entries",
|
||||
detail: "The diary is present, but it does not contain dated Dream Diary blocks.")
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "book.closed",
|
||||
title: diary.found ? "Dream diary is empty" : "No dream diary yet",
|
||||
detail: diary.found
|
||||
? "\(diary.path) exists but has no readable content."
|
||||
: "The gateway did not find DREAMS.md or dreams.md in the active agent workspace.")
|
||||
.padding(14)
|
||||
}
|
||||
} else {
|
||||
self.emptyDetailRow(
|
||||
icon: "book.closed",
|
||||
title: self.gatewayConnected ? "Diary unavailable" : "Dreaming unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not return dream diary content."
|
||||
: "Connect a gateway to read dream diary entries.")
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamDiaryDayMenu(days: [DreamDiaryDay], selectedDay: DreamDiaryDay?) -> some View {
|
||||
Menu {
|
||||
ForEach(Array(days.reversed())) { day in
|
||||
Button {
|
||||
self.selectedDreamDiaryDayID = day.id
|
||||
} label: {
|
||||
Label(
|
||||
day.title,
|
||||
systemImage: day.id == selectedDay?.id ? "checkmark.circle.fill" : "calendar")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "calendar")
|
||||
Text(selectedDay?.title ?? "Day")
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 34)
|
||||
.background(Color.primary.opacity(0.055), in: Capsule())
|
||||
}
|
||||
.accessibilityLabel("Dream diary day")
|
||||
}
|
||||
|
||||
private func dreamDiaryDayView(_ day: DreamDiaryDay) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(day.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text("\(day.entryCount) \(day.entryCount == 1 ? "entry" : "entries")")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
Text(day.body)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(120)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.045), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func selectedDreamDiaryDay(from days: [DreamDiaryDay]) -> DreamDiaryDay? {
|
||||
if let selectedDreamDiaryDayID,
|
||||
let match = days.first(where: { $0.id == selectedDreamDiaryDayID })
|
||||
{
|
||||
return match
|
||||
}
|
||||
return days.last
|
||||
}
|
||||
|
||||
private func dreamingEntriesList(
|
||||
title: String,
|
||||
entries: [DreamingEntryLite],
|
||||
emptyTitle: String,
|
||||
emptyDetail: String) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: title)
|
||||
ProCard(padding: 0) {
|
||||
if entries.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "doc.text.magnifyingglass",
|
||||
title: emptyTitle,
|
||||
detail: self.gatewayConnected ? emptyDetail : "Connect a gateway to load dreaming entries.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in
|
||||
self.dreamingEntryRow(entry)
|
||||
if index < entries.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamingEntryRow(_ entry: DreamingEntryLite) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: "text.page", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.dreamingEntryTitle(entry))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(entry.snippet)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(4)
|
||||
.textSelection(.enabled)
|
||||
Text(self.dreamingEntryDetail(entry))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text("\(entry.totalSignalCount)")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private var dreamingPhasesCard: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Phases")
|
||||
ProCard(padding: 0) {
|
||||
let phases = self.dreamingPhases
|
||||
if phases.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "moon.zzz",
|
||||
title: self.gatewayConnected ? "No phase status" : "Dreaming unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not return dreaming phase details."
|
||||
: "Connect a gateway to load dreaming phases.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(phases.enumerated()), id: \.element.id) { index, phase in
|
||||
self.dreamingPhaseRow(phase)
|
||||
if index < phases.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var dreamingPhases: [DreamingPhaseRow] {
|
||||
let phaseOrder = ["light", "deep", "rem"]
|
||||
let phases = self.overview?.dreaming?.phases ?? [:]
|
||||
return phaseOrder.compactMap { id in
|
||||
guard let phase = phases[id] else { return nil }
|
||||
return DreamingPhaseRow(id: id, title: id.capitalized, status: phase)
|
||||
}
|
||||
}
|
||||
|
||||
private func dreamingPhaseRow(_ phase: DreamingPhaseRow) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: phase.status.enabled == false ? "pause.circle" : "moon.stars",
|
||||
color: phase.status.enabled == false ? .secondary : OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(phase.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.dreamingPhaseDetail(phase.status))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let cron = self.normalized(phase.status.cron) {
|
||||
Text(cron)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.dreamingPhaseState(phase.status))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(phase.status.managedCronPresent == true ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func dreamingEntryTitle(_ entry: DreamingEntryLite) -> String {
|
||||
let path = entry.path.split(separator: "/").last.map(String.init) ?? entry.path
|
||||
return "\(path):\(entry.startLine)"
|
||||
}
|
||||
|
||||
private func dreamingEntryDetail(_ entry: DreamingEntryLite) -> String {
|
||||
let parts = [
|
||||
entry.promotedAt.map { "promoted \($0)" },
|
||||
entry.lastRecalledAt.map { "recalled \($0)" },
|
||||
"\(entry.recallCount) recalls",
|
||||
"\(entry.groundedCount) grounded",
|
||||
].compactMap(\.self)
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func dreamingPhaseDetail(_ phase: DreamingPhaseStatusLite) -> String {
|
||||
if let nextRunAtMs = phase.nextRunAtMs {
|
||||
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
if phase.managedCronPresent == true {
|
||||
return "Managed cron is installed."
|
||||
}
|
||||
return "Managed cron is not installed."
|
||||
}
|
||||
|
||||
private func dreamingPhaseState(_ phase: DreamingPhaseStatusLite) -> String {
|
||||
if phase.enabled == false { return "off" }
|
||||
return phase.managedCronPresent == true ? "scheduled" : "setup"
|
||||
}
|
||||
|
||||
private func dreamDiaryUpdatedLabel(_ diary: DreamDiaryLite) -> String {
|
||||
guard let updatedAtMs = diary.updatedAtMs else { return "No update timestamp" }
|
||||
return "Updated \(Self.relativeTime(fromMilliseconds: updatedAtMs))"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runDreamAction(_ action: DreamAction) async {
|
||||
guard self.gatewayConnected, self.dreamActionBusy == nil else { return }
|
||||
self.dreamActionBusy = action
|
||||
self.dreamActionStatusText = nil
|
||||
defer { self.dreamActionBusy = nil }
|
||||
|
||||
do {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: action.method,
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 30)
|
||||
self.dreamActionStatusText = Self.dreamActionSummary(action: action, data: data)
|
||||
await self.refresh()
|
||||
} catch {
|
||||
self.dreamActionStatusText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private static func dreamActionSummary(action: DreamAction, data: Data) -> String {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
return "\(action.title) complete."
|
||||
}
|
||||
let written = json["written"] as? Int
|
||||
let replaced = json["replaced"] as? Int
|
||||
let removed = json["removedEntries"] as? Int
|
||||
let changed = json["changed"] as? Bool
|
||||
let parts = [
|
||||
written.map { "\($0) written" },
|
||||
replaced.map { "\($0) replaced" },
|
||||
removed.map { "\($0) removed" },
|
||||
changed.map { $0 ? "artifacts repaired" : "no repair needed" },
|
||||
].compactMap(\.self)
|
||||
if parts.isEmpty {
|
||||
return "\(action.title) complete."
|
||||
}
|
||||
return "\(action.title): \(parts.joined(separator: ", "))."
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func normalizedMultiline(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func compactNumber(_ value: Int) -> String {
|
||||
value.formatted(.number.notation(.compactName))
|
||||
}
|
||||
|
||||
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
|
||||
private static func dreamDiaryDays(from content: String) -> [DreamDiaryDay] {
|
||||
let inner = Self.dreamDiaryInnerContent(content)
|
||||
let separatorBlocks = inner
|
||||
.components(separatedBy: "\n---")
|
||||
.flatMap { $0.components(separatedBy: "\r\n---") }
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let blocks = separatorBlocks.count > 1 ? separatorBlocks : Self.splitDiaryBlocksByDateLine(inner)
|
||||
let parsedBlocks = blocks.enumerated().map { index, block in
|
||||
Self.dreamDiaryBlock(from: block, index: index)
|
||||
}.filter(\.hasDatedEntry)
|
||||
return Self.mergeDiaryBlocksByDay(parsedBlocks)
|
||||
}
|
||||
|
||||
private static func dreamDiaryInnerContent(_ content: String) -> String {
|
||||
let start = "<!-- openclaw:dreaming:diary:start -->"
|
||||
let end = "<!-- openclaw:dreaming:diary:end -->"
|
||||
guard let startRange = content.range(of: start),
|
||||
let endRange = content.range(of: end, range: startRange.upperBound..<content.endIndex)
|
||||
else {
|
||||
return content
|
||||
}
|
||||
return String(content[startRange.upperBound..<endRange.lowerBound])
|
||||
}
|
||||
|
||||
private static func dreamDiaryBlock(from block: String, index: Int) -> DreamDiaryDay {
|
||||
let rawLines = block.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
|
||||
let dateLineIndex = rawLines.firstIndex { line in
|
||||
Self.isDiaryDateLine(line)
|
||||
}
|
||||
let markerDay = rawLines.compactMap(Self.backfillDay).first
|
||||
let rawTitle = dateLineIndex.flatMap { Self.unwrappedEmphasis(rawLines[$0]) } ?? markerDay
|
||||
let title = rawTitle.map(Self.dayTitle) ?? markerDay ?? "Diary"
|
||||
let id = markerDay ?? Self.dayID(title)
|
||||
let bodyLines = rawLines.enumerated().compactMap { offset, line -> String? in
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if offset == dateLineIndex { return nil }
|
||||
if trimmed.hasPrefix("<!--") && trimmed.hasSuffix("-->") { return nil }
|
||||
if trimmed == "#" || trimmed == "# Dream Diary" { return nil }
|
||||
return line
|
||||
}
|
||||
let body = bodyLines
|
||||
.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return DreamDiaryDay(
|
||||
id: id.isEmpty ? "\(index)" : id,
|
||||
title: title,
|
||||
body: body.isEmpty ? "No diary prose for this day." : body,
|
||||
entryCount: 1,
|
||||
hasDatedEntry: rawTitle != nil)
|
||||
}
|
||||
|
||||
private static func mergeDiaryBlocksByDay(_ blocks: [DreamDiaryDay]) -> [DreamDiaryDay] {
|
||||
var ordered: [DreamDiaryDay] = []
|
||||
for block in blocks {
|
||||
if let existingIndex = ordered.firstIndex(where: { $0.title == block.title }) {
|
||||
let existing = ordered[existingIndex]
|
||||
ordered[existingIndex] = DreamDiaryDay(
|
||||
id: existing.id,
|
||||
title: existing.title,
|
||||
body: [existing.body, block.body].joined(separator: "\n\n---\n\n"),
|
||||
entryCount: existing.entryCount + block.entryCount,
|
||||
hasDatedEntry: true)
|
||||
} else {
|
||||
ordered.append(block)
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func splitDiaryBlocksByDateLine(_ content: String) -> [String] {
|
||||
var blocks: [String] = []
|
||||
var current: [String] = []
|
||||
for line in content.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
if Self.isDiaryDateLine(line), !current.isEmpty {
|
||||
blocks.append(current.joined(separator: "\n"))
|
||||
current = []
|
||||
}
|
||||
current.append(line)
|
||||
}
|
||||
if !current.isEmpty {
|
||||
blocks.append(current.joined(separator: "\n"))
|
||||
}
|
||||
return blocks
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private static func isDiaryDateLine(_ line: String) -> Bool {
|
||||
guard let value = unwrappedEmphasis(line) else { return false }
|
||||
let monthNames = "January|February|March|April|May|June|July|August|September|October|November|December"
|
||||
let monthDatePattern = #"\b("# + monthNames + #")\s+\d{1,2},\s+\d{4}\b"#
|
||||
let isoDatePattern = #"\b\d{4}-\d{2}-\d{2}\b"#
|
||||
return value.range(
|
||||
of: "\(monthDatePattern)|\(isoDatePattern)",
|
||||
options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private static func dayTitle(_ rawTitle: String) -> String {
|
||||
let noTime = rawTitle.replacingOccurrences(
|
||||
of: #"\s+at\s+\d{1,2}:\d{2}.*$"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return noTime.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func dayID(_ title: String) -> String {
|
||||
title.lowercased()
|
||||
.replacingOccurrences(of: #"[^a-z0-9]+"#, with: "-", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
}
|
||||
|
||||
private static func unwrappedEmphasis(_ line: String) -> String? {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("*"), trimmed.hasSuffix("*"), trimmed.count > 2 else { return nil }
|
||||
return String(trimmed.dropFirst().dropLast())
|
||||
}
|
||||
|
||||
private static func backfillDay(_ line: String) -> String? {
|
||||
guard let range = line.range(of: #"day=\d{4}-\d{2}-\d{2}"#, options: .regularExpression) else {
|
||||
return nil
|
||||
}
|
||||
return String(line[range].dropFirst(4))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamDiaryDay: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let body: String
|
||||
let entryCount: Int
|
||||
let hasDatedEntry: Bool
|
||||
}
|
||||
368
apps/ios/Sources/Design/AgentProModels.swift
Normal file
368
apps/ios/Sources/Design/AgentProModels.swift
Normal file
@@ -0,0 +1,368 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
|
||||
enum AgentProValueReader {
|
||||
static func intValue(_ value: AnyCodable?) -> Int? {
|
||||
switch value?.value {
|
||||
case let int as Int: int
|
||||
case let double as Double where double.isFinite: Int(double)
|
||||
case let string as String: Int(string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
static func doubleValue(_ value: AnyCodable?) -> Double? {
|
||||
switch value?.value {
|
||||
case let double as Double where double.isFinite: double
|
||||
case let int as Int: Double(int)
|
||||
case let string as String: Double(string)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentOverviewSnapshot {
|
||||
let skills: SkillStatusReportLite?
|
||||
let presence: [PresenceEntry]
|
||||
let cronStatus: CronStatusLite?
|
||||
let cronJobs: [CronJob]
|
||||
let dreaming: DreamingStatusLite?
|
||||
let dreamDiary: DreamDiaryLite?
|
||||
let usage: CostUsageSummaryLite?
|
||||
let activeAgentId: String
|
||||
let agentSkillFilter: [String]?
|
||||
let loadedAt: Date
|
||||
|
||||
var hasAnyLiveData: Bool {
|
||||
self.skills != nil
|
||||
|| !self.presence.isEmpty
|
||||
|| self.cronStatus != nil
|
||||
|| !self.cronJobs.isEmpty
|
||||
|| self.dreaming != nil
|
||||
|| self.dreamDiary != nil
|
||||
|| self.usage != nil
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillStatusReportLite: Decodable {
|
||||
let workspaceDir: String?
|
||||
let managedSkillsDir: String?
|
||||
let agentId: String?
|
||||
let agentSkillFilter: [String]?
|
||||
let skills: [SkillStatusEntryLite]
|
||||
|
||||
var totalCount: Int {
|
||||
self.skills.count
|
||||
}
|
||||
|
||||
var enabledCount: Int {
|
||||
self.skills.count {
|
||||
$0.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var blockedCount: Int {
|
||||
self.skills.count {
|
||||
$0.blockedByAllowlist == true || $0.blockedByAgentFilter == true
|
||||
}
|
||||
}
|
||||
|
||||
var missingRequirementCount: Int {
|
||||
self.skills.count {
|
||||
$0.hasMissingRequirements
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillStatusEntryLite: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let source: String?
|
||||
let filePath: String?
|
||||
let skillKey: String?
|
||||
let primaryEnv: String?
|
||||
let emoji: String?
|
||||
let homepage: String?
|
||||
let disabled: Bool?
|
||||
let blockedByAllowlist: Bool?
|
||||
let blockedByAgentFilter: Bool?
|
||||
let missing: SkillStatusMissingLite?
|
||||
let install: [SkillInstallOptionLite]?
|
||||
|
||||
var displayName: String {
|
||||
if let emoji, !emoji.isEmpty {
|
||||
return "\(emoji) \(self.name)"
|
||||
}
|
||||
return self.name
|
||||
}
|
||||
|
||||
var effectiveSkillKey: String {
|
||||
let trimmed = (self.skillKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? self.name : trimmed
|
||||
}
|
||||
|
||||
var isGloballyEnabled: Bool {
|
||||
self.disabled != true
|
||||
}
|
||||
|
||||
var isEnabled: Bool {
|
||||
self.disabled != true
|
||||
&& self.blockedByAllowlist != true
|
||||
&& self.blockedByAgentFilter != true
|
||||
}
|
||||
|
||||
var hasMissingRequirements: Bool {
|
||||
guard let missing else { return false }
|
||||
return !missing.bins.isEmpty
|
||||
|| !missing.env.isEmpty
|
||||
|| !missing.config.isEmpty
|
||||
|| !missing.os.isEmpty
|
||||
}
|
||||
|
||||
var missingSummary: String? {
|
||||
guard let missing else { return nil }
|
||||
let values = [
|
||||
missing.bins,
|
||||
missing.env,
|
||||
missing.config,
|
||||
missing.os,
|
||||
].flatMap(\.self)
|
||||
return values.isEmpty ? nil : values.prefix(3).joined(separator: ", ")
|
||||
}
|
||||
|
||||
var installSummary: String? {
|
||||
guard let option = self.install?.first else { return nil }
|
||||
return option.label
|
||||
}
|
||||
|
||||
var missingBins: [String] {
|
||||
self.missing?.bins ?? []
|
||||
}
|
||||
|
||||
var homepageURL: URL? {
|
||||
guard let homepage else { return nil }
|
||||
return URL(string: homepage)
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillInstallOptionLite: Decodable {
|
||||
let id: String?
|
||||
let kind: String?
|
||||
let label: String
|
||||
let bins: [String]?
|
||||
}
|
||||
|
||||
struct SkillUpdateParams: Encodable {
|
||||
let skillKey: String
|
||||
var enabled: Bool?
|
||||
var apiKey: String?
|
||||
}
|
||||
|
||||
struct SkillInstallParams: Encodable {
|
||||
let name: String
|
||||
let installId: String
|
||||
let timeoutMs: Int
|
||||
}
|
||||
|
||||
struct SkillInstallResultLite: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
|
||||
struct ClawHubSearchParams: Encodable {
|
||||
let query: String?
|
||||
let limit: Int
|
||||
}
|
||||
|
||||
struct ClawHubSearchResponseLite: Decodable {
|
||||
let results: [ClawHubSearchResultLite]
|
||||
}
|
||||
|
||||
struct ClawHubSearchResultLite: Decodable {
|
||||
let slug: String
|
||||
let displayName: String
|
||||
let summary: String?
|
||||
let version: String?
|
||||
}
|
||||
|
||||
struct ClawHubInstallParams: Encodable {
|
||||
let source = "clawhub"
|
||||
let slug: String
|
||||
}
|
||||
|
||||
struct CronRunParams: Encodable {
|
||||
let id: String
|
||||
let mode: String
|
||||
}
|
||||
|
||||
struct CronUpdatePatch: Encodable {
|
||||
let enabled: Bool
|
||||
}
|
||||
|
||||
struct CronUpdateParams: Encodable {
|
||||
let id: String
|
||||
let patch: CronUpdatePatch
|
||||
}
|
||||
|
||||
struct SkillStatusMissingLite: Decodable {
|
||||
let bins: [String]
|
||||
let env: [String]
|
||||
let config: [String]
|
||||
let os: [String]
|
||||
}
|
||||
|
||||
struct CronStatusLite: Decodable {
|
||||
let enabled: Bool
|
||||
let jobs: Int
|
||||
let nextwakeatms: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case enabled
|
||||
case jobs
|
||||
case nextwakeatms = "nextWakeAtMs"
|
||||
}
|
||||
}
|
||||
|
||||
struct CronJobsListLite: Decodable {
|
||||
let jobs: [CronJob]
|
||||
let total: Int?
|
||||
}
|
||||
|
||||
struct DreamingStatusEnvelope: Decodable {
|
||||
let dreaming: DreamingStatusLite?
|
||||
}
|
||||
|
||||
struct DreamingStatusLite: Decodable {
|
||||
let enabled: Bool
|
||||
let shortTermCount: Int?
|
||||
let totalSignalCount: Int?
|
||||
let promotedToday: Int?
|
||||
let storeError: String?
|
||||
let shortTermEntries: [DreamingEntryLite]?
|
||||
let signalEntries: [DreamingEntryLite]?
|
||||
let promotedEntries: [DreamingEntryLite]?
|
||||
let phases: [String: DreamingPhaseStatusLite]?
|
||||
|
||||
var nextRunAtMs: Int? {
|
||||
self.phases?.values
|
||||
.compactMap(\.nextRunAtMs)
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamingEntryLite: Decodable, Identifiable {
|
||||
let key: String
|
||||
let path: String
|
||||
let startLine: Int
|
||||
let endLine: Int
|
||||
let snippet: String
|
||||
let recallCount: Int
|
||||
let dailyCount: Int
|
||||
let groundedCount: Int
|
||||
let totalSignalCount: Int
|
||||
let lightHits: Int
|
||||
let remHits: Int
|
||||
let phaseHitCount: Int
|
||||
let promotedAt: String?
|
||||
let lastRecalledAt: String?
|
||||
|
||||
var id: String {
|
||||
"\(self.key):\(self.path):\(self.startLine):\(self.endLine)"
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamDiaryLite: Decodable {
|
||||
let agentId: String
|
||||
let found: Bool
|
||||
let path: String
|
||||
let content: String?
|
||||
let updatedAtMs: Int?
|
||||
}
|
||||
|
||||
struct DreamingPhaseStatusLite: Decodable {
|
||||
let enabled: Bool?
|
||||
let cron: String?
|
||||
let managedCronPresent: Bool?
|
||||
let nextRunAtMs: Int?
|
||||
}
|
||||
|
||||
struct DreamingPhaseRow: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let status: DreamingPhaseStatusLite
|
||||
}
|
||||
|
||||
struct ConfigSnapshotLite: Decodable {
|
||||
let hash: String?
|
||||
let config: ConfigRootLite?
|
||||
|
||||
func agentConfig(id: String) -> AgentConfigLite? {
|
||||
self.config?.agents?.list?.first { $0.id == id }
|
||||
}
|
||||
|
||||
func effectiveSkillFilter(agentId: String) -> [String]? {
|
||||
if let agentSkills = self.agentConfig(id: agentId)?.skills {
|
||||
return agentSkills
|
||||
}
|
||||
return self.config?.agents?.defaults?.skills
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigRootLite: Decodable {
|
||||
let agents: AgentsConfigLite?
|
||||
}
|
||||
|
||||
struct AgentsConfigLite: Decodable {
|
||||
let defaults: AgentDefaultsConfigLite?
|
||||
let list: [AgentConfigLite]?
|
||||
}
|
||||
|
||||
struct AgentDefaultsConfigLite: Decodable {
|
||||
let skills: [String]?
|
||||
}
|
||||
|
||||
struct AgentConfigLite: Decodable {
|
||||
let id: String
|
||||
let skills: [String]?
|
||||
}
|
||||
|
||||
struct ConfigPatchParams: Encodable {
|
||||
let raw: String
|
||||
let baseHash: String
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
case missingConfigHash
|
||||
case invalidPatchPayload
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingConfigHash:
|
||||
"Config hash missing; refresh and retry."
|
||||
case .invalidPatchPayload:
|
||||
"Could not encode the skill config update."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CostUsageSummaryLite: Decodable {
|
||||
let updatedAt: Int?
|
||||
let days: Int?
|
||||
let daily: [CostUsageDailyEntryLite]?
|
||||
let totals: [String: AnyCodable]?
|
||||
let cacheStatus: [String: AnyCodable]?
|
||||
|
||||
var totalCost: Double? {
|
||||
AgentProValueReader.doubleValue(self.totals?["totalCost"])
|
||||
}
|
||||
|
||||
var totalTokens: Int? {
|
||||
AgentProValueReader.intValue(self.totals?["totalTokens"])
|
||||
}
|
||||
}
|
||||
|
||||
struct CostUsageDailyEntryLite: Decodable {
|
||||
let date: String
|
||||
let totalTokens: Int?
|
||||
let totalCost: Double?
|
||||
}
|
||||
348
apps/ios/Sources/Design/AgentProNodesDestination.swift
Normal file
348
apps/ios/Sources/Design/AgentProNodesDestination.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AgentProNodesDestination: View {
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let agentCount: Int
|
||||
let instancesValue: String
|
||||
let instancesDetail: String
|
||||
let instancesColor: Color
|
||||
let refresh: () async -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.summaryCard
|
||||
self.totalsCard
|
||||
self.nodesList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refresh()
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Nodes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: self.instancesColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Nodes")
|
||||
.font(.headline)
|
||||
Text(self.instancesDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.instancesValue, color: self.instancesColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var totalsCard: some View {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Presence")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: self.instancesValue, color: self.instancesColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(label: "Connected", value: "\(self.overview?.presence.count ?? 0)")
|
||||
self.detailMetric(label: "Agents", value: "\(self.agentCount)")
|
||||
self.detailMetric(label: "Gateway", value: self.gatewayConnected ? "online" : "offline")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var nodesList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Connected Nodes")
|
||||
ProCard(padding: 0) {
|
||||
let nodes = self.sortedPresenceEntries
|
||||
if nodes.isEmpty {
|
||||
self.emptyRow(
|
||||
icon: "display",
|
||||
title: self.gatewayConnected ? "No nodes connected" : "Nodes unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "The gateway did not report any system presence entries."
|
||||
: "Connect a gateway to inspect connected nodes.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(nodes.enumerated()), id: \.element.presenceKey) { index, entry in
|
||||
NavigationLink {
|
||||
self.nodeDetail(entry)
|
||||
} label: {
|
||||
self.nodePresenceRow(entry, showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < nodes.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var sortedPresenceEntries: [PresenceEntry] {
|
||||
(self.overview?.presence ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.ts != rhs.ts { return lhs.ts > rhs.ts }
|
||||
return (Self.presenceLabel(lhs) ?? lhs.presenceKey)
|
||||
.localizedCaseInsensitiveCompare(Self.presenceLabel(rhs) ?? rhs.presenceKey) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func nodePresenceRow(_ entry: PresenceEntry, showsChevron: Bool = false) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let meta = Self.presenceMeta(entry) {
|
||||
Text(meta)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(Self.presenceState(entry))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(Self.presenceColor(entry))
|
||||
.lineLimit(1)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
private func nodeDetail(_ entry: PresenceEntry) -> some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
.font(.headline)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: Self.presenceState(entry), color: Self.presenceColor(entry))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
ProCard {
|
||||
VStack(spacing: 0) {
|
||||
self.nodeDetailRow("Instance", value: entry.instanceid)
|
||||
Divider()
|
||||
self.nodeDetailRow("Device", value: entry.deviceid)
|
||||
Divider()
|
||||
self.nodeDetailRow("Host", value: entry.host)
|
||||
Divider()
|
||||
self.nodeDetailRow("IP", value: entry.ip)
|
||||
Divider()
|
||||
self.nodeDetailRow("Platform", value: entry.platform)
|
||||
Divider()
|
||||
self.nodeDetailRow("Version", value: entry.version)
|
||||
Divider()
|
||||
self.nodeDetailRow("Mode", value: entry.mode)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.nodeListCard(title: "Scopes", values: entry.scopes ?? [])
|
||||
self.nodeListCard(title: "Roles", values: entry.roles ?? [])
|
||||
self.nodeListCard(title: "Tags", values: entry.tags ?? [])
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func nodeDetailRow(_ title: String, value: String?) -> some View {
|
||||
let normalized = Self.normalized(value) ?? "n/a"
|
||||
return HStack(spacing: 10) {
|
||||
Text(title)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(normalized)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Button {
|
||||
UIPasteboard.general.string = normalized
|
||||
} label: {
|
||||
Image(systemName: "doc.on.doc")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(normalized == "n/a")
|
||||
.accessibilityLabel("Copy \(title)")
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func nodeListCard(title: String, values: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: title)
|
||||
ProCard {
|
||||
if values.isEmpty {
|
||||
Text("None reported.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text(value)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
private func emptyRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private static func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
?? self.normalized(entry.platform)
|
||||
?? self.normalized(entry.mode)
|
||||
}
|
||||
|
||||
private static func presenceDetail(_ entry: PresenceEntry) -> String {
|
||||
let parts = [
|
||||
Self.normalized(entry.ip),
|
||||
Self.normalized(entry.platform),
|
||||
Self.normalized(entry.version),
|
||||
].compactMap(\.self)
|
||||
if !parts.isEmpty {
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
return Self.normalized(entry.text) ?? "Presence beacon received."
|
||||
}
|
||||
|
||||
private static func presenceMeta(_ entry: PresenceEntry) -> String? {
|
||||
let tags = (entry.tags ?? []).prefix(2).joined(separator: ", ")
|
||||
let scopesCount = entry.scopes?.count ?? 0
|
||||
let rolesCount = entry.roles?.count ?? 0
|
||||
let labels = [
|
||||
Self.normalized(entry.instanceid).map { "instance \($0)" },
|
||||
tags.isEmpty ? nil : tags,
|
||||
scopesCount > 0 ? "\(scopesCount) scopes" : nil,
|
||||
rolesCount > 0 ? "\(rolesCount) roles" : nil,
|
||||
].compactMap(\.self)
|
||||
return labels.isEmpty ? nil : labels.joined(separator: " • ")
|
||||
}
|
||||
|
||||
private static func presenceState(_ entry: PresenceEntry) -> String {
|
||||
if let reason = normalized(entry.reason) {
|
||||
return reason
|
||||
}
|
||||
if let mode = Self.normalized(entry.mode) {
|
||||
return mode
|
||||
}
|
||||
return Self.relativeTime(fromMilliseconds: entry.ts)
|
||||
}
|
||||
|
||||
private static func presenceIcon(_ entry: PresenceEntry) -> String {
|
||||
let family = Self.normalized(entry.devicefamily)?.lowercased()
|
||||
if family?.contains("phone") == true { return "iphone" }
|
||||
if family?.contains("tablet") == true || family?.contains("pad") == true { return "ipad" }
|
||||
if family?.contains("desktop") == true || family?.contains("mac") == true { return "desktopcomputer" }
|
||||
return "display"
|
||||
}
|
||||
|
||||
private static func presenceColor(_ entry: PresenceEntry) -> Color {
|
||||
self.normalized(entry.reason) == nil ? OpenClawBrand.accent : OpenClawBrand.warn
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
}
|
||||
|
||||
extension PresenceEntry {
|
||||
fileprivate var presenceKey: String {
|
||||
self.instanceid
|
||||
?? self.deviceid
|
||||
?? self.host
|
||||
?? self.ip
|
||||
?? "\(self.ts)"
|
||||
}
|
||||
}
|
||||
178
apps/ios/Sources/Design/AgentProTab+Cron.swift
Normal file
178
apps/ios/Sources/Design/AgentProTab+Cron.swift
Normal file
@@ -0,0 +1,178 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var cronStatusCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Scheduler")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(
|
||||
value: self.overview?.cronStatus?.enabled == true ? "on" : "off",
|
||||
color: self.cronColor)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
let jobCount = self.overview?.cronStatus?.jobs
|
||||
?? self.overview?.cronJobs.count
|
||||
?? 0
|
||||
self.detailMetric(label: "Jobs", value: "\(jobCount)")
|
||||
self.detailMetric(label: "Next", value: self.cronNextRunLabel)
|
||||
}
|
||||
if let cronActionStatusText {
|
||||
Text(cronActionStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var cronNextRunLabel: String {
|
||||
guard let nextWakeAtMs = self.overview?.cronStatus?.nextwakeatms else { return "none" }
|
||||
return Self.relativeTime(fromMilliseconds: nextWakeAtMs)
|
||||
}
|
||||
|
||||
func cronJobsList(limit: Int?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Jobs")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let jobs = self.sortedCronJobs
|
||||
let visible = limit.map { Array(jobs.prefix($0)) } ?? jobs
|
||||
if visible.isEmpty {
|
||||
self.emptyCronRow
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(visible.enumerated()), id: \.element.id) { index, job in
|
||||
self.cronJobDetailRow(job)
|
||||
if index < visible.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var sortedCronJobs: [CronJob] {
|
||||
(self.overview?.cronJobs ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
|
||||
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
|
||||
switch (lhsNext, rhsNext) {
|
||||
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cronJobDetailRow(_ job: CronJob) -> some View {
|
||||
let busy = self.cronActionBusyIDs.contains(job.id)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
|
||||
color: job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(job.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.cronJobDetail(job))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Text(self.cronScheduleSummary(job))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.runCronJob(job) }
|
||||
} label: {
|
||||
Label("Run", systemImage: "play.fill")
|
||||
}
|
||||
.disabled(busy || !self.gatewayConnected)
|
||||
|
||||
Button {
|
||||
Task { await self.setCronJob(job, enabled: !job.enabled) }
|
||||
} label: {
|
||||
Label(job.enabled ? "Pause" : "Enable", systemImage: job.enabled ? "pause.fill" : "checkmark")
|
||||
}
|
||||
.disabled(busy || !self.gatewayConnected)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if busy {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Text(self.cronJobState(job))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runCronJob(_ job: CronJob) async {
|
||||
await self.runCronAction(job, success: "Queued \(job.name).") {
|
||||
let params = CronRunParams(id: job.id, mode: "force")
|
||||
_ = try await self.requestGateway(method: "cron.run", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setCronJob(_ job: CronJob, enabled: Bool) async {
|
||||
await self.runCronAction(job, success: enabled ? "Enabled \(job.name)." : "Paused \(job.name).") {
|
||||
let params = CronUpdateParams(id: job.id, patch: CronUpdatePatch(enabled: enabled))
|
||||
_ = try await self.requestGateway(method: "cron.update", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runCronAction(
|
||||
_ job: CronJob,
|
||||
success: String,
|
||||
action: () async throws -> Void) async
|
||||
{
|
||||
guard self.gatewayConnected else { return }
|
||||
self.cronActionBusyIDs.insert(job.id)
|
||||
self.cronActionStatusText = nil
|
||||
defer { self.cronActionBusyIDs.remove(job.id) }
|
||||
do {
|
||||
try await action()
|
||||
self.cronActionStatusText = success
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.cronActionStatusText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
func cronScheduleSummary(_ job: CronJob) -> String {
|
||||
guard let schedule = job.schedule.value as? [String: AnyCodable] else { return "Schedule configured" }
|
||||
if let expr = Self.stringValue(schedule["expr"]) {
|
||||
return "Cron \(expr)"
|
||||
}
|
||||
if let everyMs = AgentProValueReader.intValue(schedule["everyMs"]) {
|
||||
return "Every \(Self.duration(milliseconds: everyMs))"
|
||||
}
|
||||
if let kind = Self.stringValue(schedule["kind"]) {
|
||||
return kind
|
||||
}
|
||||
return "Schedule configured"
|
||||
}
|
||||
}
|
||||
148
apps/ios/Sources/Design/AgentProTab+Destinations.swift
Normal file
148
apps/ios/Sources/Design/AgentProTab+Destinations.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: AgentRoute) -> some View {
|
||||
switch route {
|
||||
case .skills:
|
||||
self.skillsDestination
|
||||
case .nodes:
|
||||
self.nodesDestination
|
||||
case .cron:
|
||||
self.cronDestination
|
||||
case .usage:
|
||||
self.usageDestination
|
||||
case .dreaming:
|
||||
self.dreamingDestination
|
||||
}
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "sparkles",
|
||||
title: "Skills",
|
||||
value: self.skillsValue,
|
||||
detail: self.skillsDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
|
||||
self.skillsPolicyControls
|
||||
self.skillsFilterField
|
||||
self.clawHubSearchCard
|
||||
self.skillsList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Skills")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var nodesDestination: some View {
|
||||
AgentProNodesDestination(
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
agentCount: self.appModel.gatewayAgents.count,
|
||||
instancesValue: self.instancesValue,
|
||||
instancesDetail: self.instancesDetail,
|
||||
instancesColor: self.instancesColor,
|
||||
refresh: {
|
||||
await self.refreshOverview(force: true)
|
||||
})
|
||||
}
|
||||
|
||||
var cronDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron Jobs",
|
||||
value: self.cronValue,
|
||||
detail: self.cronDetail,
|
||||
color: self.cronColor)
|
||||
self.cronStatusCard
|
||||
self.cronJobsList(limit: nil)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Cron Jobs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var usageDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.detailSummaryCard(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
value: self.usageValue,
|
||||
detail: self.usageDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary)
|
||||
self.usageTotalsCard
|
||||
self.usageDailyList
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Usage")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var dreamingDestination: some View {
|
||||
AgentProDreamingDestination(
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
overviewLoading: self.overviewLoading,
|
||||
dreamingValue: self.dreamingValue,
|
||||
dreamingDetail: self.dreamingDetail,
|
||||
dreamingColor: self.dreamingColor,
|
||||
refresh: {
|
||||
await self.refreshOverview(force: true)
|
||||
})
|
||||
}
|
||||
|
||||
func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
35
apps/ios/Sources/Design/AgentProTab+DetailComponents.swift
Normal file
35
apps/ios/Sources/Design/AgentProTab+DetailComponents.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
func detailMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(label)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
func emptyDetailRow(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
251
apps/ios/Sources/Design/AgentProTab+GatewayData.swift
Normal file
251
apps/ios/Sources/Design/AgentProTab+GatewayData.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
func agentName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
func agentBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
|
||||
let words = self.agentName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
return initials.isEmpty ? "OC" : initials.uppercased()
|
||||
}
|
||||
|
||||
func agentTint(for agent: AgentSummary, state: AgentRosterState) -> Color {
|
||||
if agent.id == self.activeAgentID { return OpenClawBrand.accent }
|
||||
return state.color.opacity(0.62)
|
||||
}
|
||||
|
||||
func agentDetail(for agent: AgentSummary) -> String {
|
||||
let parts = [
|
||||
self.normalized(agent.workspace),
|
||||
self.modelLabel(for: agent),
|
||||
agent.id == self.appModel.gatewayDefaultAgentId ? "default" : nil,
|
||||
].compactMap(\.self)
|
||||
return parts.isEmpty ? agent.id : parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
func agentSessionSummary(_ agent: AgentSummary) -> String {
|
||||
guard self.gatewayConnected else { return "0" }
|
||||
if agent.id == self.activeAgentID {
|
||||
return self.appModel.isOperatorGatewayConnected ? "1 running" : "0"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
func agentRuntimeSummary(_ agent: AgentSummary) -> String {
|
||||
if let runtime = agent.agentruntime,
|
||||
let id = runtime["id"]?.value as? String,
|
||||
let normalized = self.normalized(id)
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
if let model = self.modelLabel(for: agent) {
|
||||
return Self.shortModelLabel(model)
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func agentRosterState(for agent: AgentSummary) -> AgentRosterState {
|
||||
guard self.gatewayConnected else { return .idle }
|
||||
if agent.id == self.activeAgentID { return .online }
|
||||
if self.cronJobsContain(agentID: agent.id) { return .busy }
|
||||
return .idle
|
||||
}
|
||||
|
||||
func cronJobsContain(agentID: String) -> Bool {
|
||||
self.recentCronJobs.contains { job in
|
||||
self.normalized(job.agentid) == agentID && job.enabled
|
||||
}
|
||||
}
|
||||
|
||||
func modelLabel(for agent: AgentSummary) -> String? {
|
||||
guard let model = agent.model else { return nil }
|
||||
for key in ["primary", "name", "id", "model"] {
|
||||
if let value = model[key]?.value as? String,
|
||||
let normalized = self.normalized(value)
|
||||
{
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func shortModelLabel(_ model: String) -> String {
|
||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "default" }
|
||||
let leaf = trimmed.split(separator: "/").last.map(String.init) ?? trimmed
|
||||
return leaf
|
||||
.replacingOccurrences(of: "claude-", with: "")
|
||||
.replacingOccurrences(of: "gpt-", with: "")
|
||||
}
|
||||
|
||||
func presenceLabel(_ entry: PresenceEntry) -> String? {
|
||||
self.normalized(entry.host)
|
||||
?? self.normalized(entry.devicefamily)
|
||||
?? self.normalized(entry.platform)
|
||||
?? self.normalized(entry.mode)
|
||||
}
|
||||
|
||||
func cronJobDetail(_ job: CronJob) -> String {
|
||||
if let nextRunAtMs = AgentProValueReader.intValue(job.state["nextRunAtMs"]) {
|
||||
return "Next \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
if let description = self.normalized(job.description) {
|
||||
return description
|
||||
}
|
||||
if let agentId = self.normalized(job.agentid) {
|
||||
return agentId
|
||||
}
|
||||
return job.id
|
||||
}
|
||||
|
||||
func cronJobState(_ job: CronJob) -> String {
|
||||
if !job.enabled {
|
||||
return "paused"
|
||||
}
|
||||
if let status = Self.stringValue(job.state["lastStatus"]) ?? Self.stringValue(job.state["lastRunStatus"]) {
|
||||
return status
|
||||
}
|
||||
return "enabled"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshOverview(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.appModel.isOperatorGatewayConnected else {
|
||||
self.overview = nil
|
||||
self.overviewErrorText = nil
|
||||
self.overviewLoading = false
|
||||
return
|
||||
}
|
||||
if self.overviewLoading, force == false {
|
||||
return
|
||||
}
|
||||
|
||||
self.overviewLoading = true
|
||||
self.overviewErrorText = nil
|
||||
defer { self.overviewLoading = false }
|
||||
|
||||
let activeAgentID = self.activeAgentID
|
||||
let skillsParams = Self.agentScopedParams(agentId: activeAgentID)
|
||||
async let skills = self.requestOptional(
|
||||
SkillStatusReportLite.self,
|
||||
method: "skills.status",
|
||||
paramsJSON: skillsParams)
|
||||
async let config = self.requestOptional(ConfigSnapshotLite.self, method: "config.get")
|
||||
async let presence = self.requestOptional([PresenceEntry].self, method: "system-presence")
|
||||
async let cronStatus = self.requestOptional(CronStatusLite.self, method: "cron.status")
|
||||
async let cronJobs = self.requestOptional(
|
||||
CronJobsListLite.self,
|
||||
method: "cron.list",
|
||||
paramsJSON: "{\"includeDisabled\":true,\"limit\":8,\"sortBy\":\"nextRunAtMs\",\"sortDir\":\"asc\"}",
|
||||
timeoutSeconds: 12)
|
||||
async let dreaming = self.requestOptional(DreamingStatusEnvelope.self, method: "doctor.memory.status")
|
||||
async let dreamDiary = self.requestOptional(DreamDiaryLite.self, method: "doctor.memory.dreamDiary")
|
||||
async let usage = self.requestOptional(
|
||||
CostUsageSummaryLite.self,
|
||||
method: "usage.cost",
|
||||
paramsJSON: "{\"days\":31}",
|
||||
timeoutSeconds: 12)
|
||||
|
||||
let loadedSkills = await skills
|
||||
let loadedConfig = await config
|
||||
let loadedPresence = await presence
|
||||
let loadedCronStatus = await cronStatus
|
||||
let loadedCronJobs = await cronJobs
|
||||
let loadedDreaming = await dreaming
|
||||
let loadedDreamDiary = await dreamDiary
|
||||
let loadedUsage = await usage
|
||||
let snapshot = AgentOverviewSnapshot(
|
||||
skills: loadedSkills,
|
||||
presence: loadedPresence ?? [],
|
||||
cronStatus: loadedCronStatus,
|
||||
cronJobs: loadedCronJobs?.jobs ?? [],
|
||||
dreaming: loadedDreaming?.dreaming,
|
||||
dreamDiary: loadedDreamDiary,
|
||||
usage: loadedUsage,
|
||||
activeAgentId: activeAgentID,
|
||||
agentSkillFilter: loadedSkills?.agentSkillFilter
|
||||
?? loadedConfig?.effectiveSkillFilter(agentId: activeAgentID),
|
||||
loadedAt: Date())
|
||||
|
||||
if snapshot.hasAnyLiveData {
|
||||
self.overview = snapshot
|
||||
} else {
|
||||
self.overview = snapshot
|
||||
self.overviewErrorText = "Live overview could not load yet."
|
||||
}
|
||||
}
|
||||
|
||||
func requestOptional<T: Decodable>(
|
||||
_ type: T.Type,
|
||||
method: String,
|
||||
paramsJSON: String = "{}",
|
||||
timeoutSeconds: Int = 8) async -> T?
|
||||
{
|
||||
do {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: paramsJSON,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func normalized(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func stringValue(_ value: AnyCodable?) -> String? {
|
||||
guard let string = value?.value as? String else { return nil }
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func relativeTime(fromMilliseconds milliseconds: Int) -> String {
|
||||
let date = Date(timeIntervalSince1970: Double(milliseconds) / 1000)
|
||||
return date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
|
||||
}
|
||||
|
||||
static func compactNumber(_ value: Int) -> String {
|
||||
value.formatted(.number.notation(.compactName))
|
||||
}
|
||||
|
||||
static func currency(_ value: Double) -> String {
|
||||
value.formatted(.currency(code: "USD").precision(.fractionLength(0...2)))
|
||||
}
|
||||
|
||||
static func duration(milliseconds: Int) -> String {
|
||||
let seconds = max(0, milliseconds / 1000)
|
||||
if seconds < 60 { return "\(seconds)s" }
|
||||
let minutes = seconds / 60
|
||||
if minutes < 60 { return "\(minutes)m" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h" }
|
||||
return "\(hours / 24)d"
|
||||
}
|
||||
|
||||
static func agentScopedParams(agentId: String) -> String {
|
||||
guard let data = try? JSONEncoder().encode(["agentId": agentId]),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return "{}"
|
||||
}
|
||||
return json
|
||||
}
|
||||
}
|
||||
724
apps/ios/Sources/Design/AgentProTab+Overview.swift
Normal file
724
apps/ios/Sources/Design/AgentProTab+Overview.swift
Normal file
@@ -0,0 +1,724 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var rosterHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Agents")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
Text("\(self.sortedAgents.count) total")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
action: {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentSearchPresented.toggle()
|
||||
}
|
||||
})
|
||||
self.headerIconButton(
|
||||
systemName: "arrow.clockwise",
|
||||
label: self.overviewLoading ? "Refreshing agents" : "Refresh agents",
|
||||
action: {
|
||||
self.overviewRefreshNonce += 1
|
||||
})
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
||||
if self.agentSearchPresented {
|
||||
TextField("Search agents", text: self.$agentSearchText)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 38)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.searchFieldFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.searchFieldStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(AgentRosterFilter.allCases) { filter in
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.18)) {
|
||||
self.agentRosterFilter = filter
|
||||
}
|
||||
} label: {
|
||||
Text(filter.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.agentRosterFilter == filter ? .primary : .secondary)
|
||||
.padding(.horizontal, 15)
|
||||
.frame(height: AgentLayout.filterHeight)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.agentRosterFilter == filter
|
||||
? Color.primary.opacity(0.13)
|
||||
: Color.primary.opacity(0.055))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(self.agentRosterFilter == filter ? 0.22 : 0.06))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if self.agentFiltersActive {
|
||||
self.headerIconButton(
|
||||
systemName: "xmark",
|
||||
label: "Clear filters",
|
||||
action: {
|
||||
self.agentRosterFilter = .all
|
||||
self.agentSearchText = ""
|
||||
})
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var agentFiltersActive: Bool {
|
||||
self.agentRosterFilter != .all
|
||||
|| !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var agentsSection: some View {
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
if self.filteredAgents.isEmpty {
|
||||
self.emptyAgentsRow
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.filteredAgents.enumerated()), id: \.element.id) { index, agent in
|
||||
self.agentRow(agent)
|
||||
if index < self.filteredAgents.count - 1 {
|
||||
Divider().padding(.leading, 76)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var operationsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Live Operations")
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
||||
self.metricTile(
|
||||
icon: "sparkles",
|
||||
title: "Skills",
|
||||
value: self.skillsValue,
|
||||
detail: self.skillsDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
|
||||
route: .skills)
|
||||
self.metricTile(
|
||||
icon: "externaldrive.connected.to.line.below",
|
||||
title: "Instances",
|
||||
value: self.instancesValue,
|
||||
detail: self.instancesDetail,
|
||||
color: self.instancesColor,
|
||||
route: .nodes)
|
||||
self.metricTile(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron",
|
||||
value: self.cronValue,
|
||||
detail: self.cronDetail,
|
||||
color: self.cronColor,
|
||||
route: .cron)
|
||||
self.metricTile(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
value: self.usageValue,
|
||||
detail: self.usageDetail,
|
||||
color: self.gatewayConnected ? OpenClawBrand.accent : .secondary,
|
||||
route: .usage)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
if let overviewErrorText {
|
||||
Text(overviewErrorText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dreamingSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Dreaming")
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
NavigationLink(value: AgentRoute.dreaming) {
|
||||
self.agentMenuRow(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
detail: self.dreamingDetail,
|
||||
value: self.dreamingValue,
|
||||
color: self.dreamingColor,
|
||||
showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var cronSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Scheduled Work")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let jobs = self.recentCronJobs
|
||||
if jobs.isEmpty {
|
||||
NavigationLink(value: AgentRoute.cron) {
|
||||
self.emptyCronRow
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(jobs.enumerated()), id: \.element.id) { index, job in
|
||||
NavigationLink(value: AgentRoute.cron) {
|
||||
self.cronJobRow(job)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < jobs.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var emptyAgentsRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "person.2.slash", color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.emptyAgentsTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.emptyAgentsDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func agentRow(_ agent: AgentSummary) -> some View {
|
||||
let isActive = agent.id == self.activeAgentID
|
||||
let state = self.agentRosterState(for: agent)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
self.agentAvatar(agent, state: state)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(self.agentName(for: agent))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(state.title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(state.color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Text(self.agentDetail(for: agent))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.agentMetric(label: "Sessions", value: self.agentSessionSummary(agent))
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.padding(.horizontal, 12)
|
||||
self.agentMetric(label: "Runtime", value: self.agentRuntimeSummary(agent))
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
Button {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
} label: {
|
||||
Image(systemName: isActive ? "checkmark" : "arrow.right")
|
||||
.font(.caption.weight(.bold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(isActive ? OpenClawBrand.accent : .primary)
|
||||
.frame(width: AgentLayout.actionButtonSize, height: AgentLayout.actionButtonSize)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.iconButtonFill)
|
||||
.overlay {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(isActive ? "Active agent" : "Make active agent")
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.padding(.horizontal, 13)
|
||||
.frame(minHeight: AgentLayout.rowMinHeight, alignment: .center)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.appModel.setSelectedAgentId(agent.id)
|
||||
}
|
||||
}
|
||||
|
||||
func headerIconButton(
|
||||
systemName: String,
|
||||
label: String,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(width: AgentLayout.filterHeight, height: AgentLayout.filterHeight)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.iconButtonFill)
|
||||
.overlay {
|
||||
Circle().strokeBorder(self.iconButtonStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
}
|
||||
|
||||
func agentAvatar(_ agent: AgentSummary, state: AgentRosterState) -> some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Text(self.agentBadge(for: agent))
|
||||
.font(.system(size: self.agentBadge(for: agent).count > 2 ? 14 : 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.62)
|
||||
.lineLimit(1)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
self.agentTint(for: agent, state: state),
|
||||
Color.primary.opacity(0.38),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
|
||||
Circle()
|
||||
.fill(state.color)
|
||||
.frame(width: 10, height: 10)
|
||||
.overlay(Circle().strokeBorder(Color.primary.opacity(0.15), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
func agentMetric(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.74)
|
||||
}
|
||||
.frame(minWidth: 60, alignment: .leading)
|
||||
}
|
||||
|
||||
func agentMenuRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color,
|
||||
showsChevron: Bool = false) -> some View
|
||||
{
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
func metricTile(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color,
|
||||
route: AgentRoute? = nil) -> some View
|
||||
{
|
||||
Group {
|
||||
if let route {
|
||||
NavigationLink(value: route) {
|
||||
self.metricTileContent(
|
||||
icon: icon,
|
||||
title: title,
|
||||
value: value,
|
||||
detail: detail,
|
||||
color: color,
|
||||
showsChevron: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
self.metricTileContent(
|
||||
icon: icon,
|
||||
title: title,
|
||||
value: value,
|
||||
detail: detail,
|
||||
color: color,
|
||||
showsChevron: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metricTileContent(
|
||||
icon: String,
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String,
|
||||
color: Color,
|
||||
showsChevron: Bool) -> some View
|
||||
{
|
||||
ProCard(padding: 12, radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
Spacer()
|
||||
ProValuePill(value: value, color: color)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: AgentLayout.metricTileHeight, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
var emptyCronRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "clock.badge.questionmark", color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.gatewayConnected ? "No scheduled jobs" : "Cron unavailable")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.gatewayConnected
|
||||
? "The gateway has no visible cron jobs."
|
||||
: "Connect a gateway to load scheduled work.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func cronJobRow(_ job: CronJob) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: job.enabled ? "clock.arrow.circlepath" : "pause.circle",
|
||||
color: job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(job.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.cronJobDetail(job))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.cronJobState(job))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(job.enabled ? OpenClawBrand.accent : .secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
var sortedAgents: [AgentSummary] {
|
||||
self.appModel.gatewayAgents.sorted { lhs, rhs in
|
||||
if lhs.id == self.activeAgentID { return true }
|
||||
if rhs.id == self.activeAgentID { return false }
|
||||
return self.agentName(for: lhs)
|
||||
.localizedCaseInsensitiveCompare(self.agentName(for: rhs)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
var filteredAgents: [AgentSummary] {
|
||||
let query = self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.sortedAgents.filter { agent in
|
||||
let matchesFilter: Bool = switch self.agentRosterFilter {
|
||||
case .all:
|
||||
true
|
||||
case .online:
|
||||
self.agentRosterState(for: agent) == .online
|
||||
case .busy:
|
||||
self.agentRosterState(for: agent) == .busy
|
||||
case .idle:
|
||||
self.agentRosterState(for: agent) == .idle
|
||||
}
|
||||
|
||||
guard matchesFilter else { return false }
|
||||
guard !query.isEmpty else { return true }
|
||||
let haystack = [
|
||||
self.agentName(for: agent),
|
||||
agent.id,
|
||||
self.normalized(agent.workspace),
|
||||
self.modelLabel(for: agent),
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.joined(separator: " ")
|
||||
return haystack.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var activeAgentID: String {
|
||||
self.normalized(self.appModel.selectedAgentId)
|
||||
?? self.normalized(self.appModel.gatewayDefaultAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var searchFieldFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
private var searchFieldStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.07)
|
||||
}
|
||||
|
||||
private var iconButtonFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.white.opacity(0.78)
|
||||
}
|
||||
|
||||
private var iconButtonStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.07)
|
||||
}
|
||||
|
||||
var emptyAgentsTitle: String {
|
||||
if !self.gatewayConnected { return "Agents unavailable" }
|
||||
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "No matches" }
|
||||
if self.agentRosterFilter != .all { return "No \(self.agentRosterFilter.title.lowercased()) agents" }
|
||||
return "No agents reported"
|
||||
}
|
||||
|
||||
var emptyAgentsDetail: String {
|
||||
if !self.gatewayConnected { return "Connect a gateway to load the live agent roster." }
|
||||
if !self.agentSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return "Try another search or clear the agent filters."
|
||||
}
|
||||
if self.agentRosterFilter != .all { return "Clear the filter to view the full roster." }
|
||||
return "The connected gateway did not return an agent list."
|
||||
}
|
||||
|
||||
var overviewTaskID: String {
|
||||
[
|
||||
self.gatewayConnected ? "connected" : "offline",
|
||||
self.appModel.isOperatorGatewayConnected ? "operator" : "no-operator",
|
||||
self.activeAgentID,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
"\(self.overviewRefreshNonce)",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
var skillsValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let skills = self.overview?.skills else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return "\(skills.enabledCount)/\(skills.totalCount)"
|
||||
}
|
||||
|
||||
var skillsDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load skills." }
|
||||
guard let skills = self.overview?.skills else {
|
||||
return self.overviewLoading ? "Loading skill status." : "Skill status is available from the gateway."
|
||||
}
|
||||
if skills.blockedCount > 0 {
|
||||
return "\(skills.enabledCount) enabled, \(skills.blockedCount) blocked"
|
||||
}
|
||||
if skills.missingRequirementCount > 0 {
|
||||
return "\(skills.enabledCount) enabled, \(skills.missingRequirementCount) need setup"
|
||||
}
|
||||
return "\(skills.enabledCount) enabled, \(skills.totalCount) installed"
|
||||
}
|
||||
|
||||
var instancesValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let count = self.overview?.presence.count else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return "\(count)"
|
||||
}
|
||||
|
||||
var instancesDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load instances." }
|
||||
guard let presence = self.overview?.presence else {
|
||||
return self.overviewLoading ? "Loading instance presence." : "Instance presence is available."
|
||||
}
|
||||
let labels = presence.prefix(2).compactMap(self.presenceLabel)
|
||||
if labels.isEmpty {
|
||||
return "No live instances reported."
|
||||
}
|
||||
return labels.joined(separator: ", ")
|
||||
}
|
||||
|
||||
var instancesColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return (self.overview?.presence.isEmpty == false) ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var cronValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let cronStatus = self.overview?.cronStatus else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return cronStatus.enabled ? "\(cronStatus.jobs)" : "off"
|
||||
}
|
||||
|
||||
var cronDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load cron." }
|
||||
guard let cronStatus = self.overview?.cronStatus else {
|
||||
return self.overviewLoading ? "Loading cron status." : "Cron status is available."
|
||||
}
|
||||
if let nextWakeAtMs = cronStatus.nextwakeatms {
|
||||
return "Next wake \(Self.relativeTime(fromMilliseconds: nextWakeAtMs))"
|
||||
}
|
||||
return cronStatus.enabled ? "Scheduler enabled" : "Scheduler disabled"
|
||||
}
|
||||
|
||||
var cronColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return self.overview?.cronStatus?.enabled == true ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var usageValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let usage = self.overview?.usage else {
|
||||
return self.overviewLoading ? "..." : "7d"
|
||||
}
|
||||
if let cost = usage.totalCost {
|
||||
return Self.currency(cost)
|
||||
}
|
||||
if let tokens = usage.totalTokens, tokens > 0 {
|
||||
return Self.compactNumber(tokens)
|
||||
}
|
||||
return "7d"
|
||||
}
|
||||
|
||||
var usageDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load usage." }
|
||||
guard let usage = self.overview?.usage else {
|
||||
return self.overviewLoading ? "Loading recent usage." : "Recent usage is available."
|
||||
}
|
||||
if let tokens = usage.totalTokens, tokens > 0 {
|
||||
return "\(Self.compactNumber(tokens)) tokens in \(usage.days ?? 7)d"
|
||||
}
|
||||
return "No token usage reported for \(usage.days ?? 7)d."
|
||||
}
|
||||
|
||||
var dreamingValue: String {
|
||||
guard self.gatewayConnected else { return "offline" }
|
||||
guard let dreaming = self.overview?.dreaming else {
|
||||
return self.overviewLoading ? "..." : "live"
|
||||
}
|
||||
return dreaming.enabled ? "on" : "off"
|
||||
}
|
||||
|
||||
var dreamingDetail: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to load dreaming." }
|
||||
guard let dreaming = self.overview?.dreaming else {
|
||||
return self.overviewLoading ? "Loading dreaming status." : "Background memory status is available."
|
||||
}
|
||||
if let nextRunAtMs = dreaming.nextRunAtMs {
|
||||
return "Next cycle \(Self.relativeTime(fromMilliseconds: nextRunAtMs))"
|
||||
}
|
||||
return "\(dreaming.totalSignalCount ?? 0) signals, \(dreaming.promotedToday ?? 0) promoted today"
|
||||
}
|
||||
|
||||
var dreamingColor: Color {
|
||||
guard self.gatewayConnected else { return .secondary }
|
||||
return self.overview?.dreaming?.enabled == true ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var recentCronJobs: [CronJob] {
|
||||
(self.overview?.cronJobs ?? [])
|
||||
.sorted { lhs, rhs in
|
||||
let lhsNext = AgentProValueReader.intValue(lhs.state["nextRunAtMs"])
|
||||
let rhsNext = AgentProValueReader.intValue(rhs.state["nextRunAtMs"])
|
||||
switch (lhsNext, rhsNext) {
|
||||
case let (lhsNext?, rhsNext?): return lhsNext < rhsNext
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.updatedatms > rhs.updatedatms
|
||||
}
|
||||
}
|
||||
.prefix(4)
|
||||
.map(\.self)
|
||||
}
|
||||
}
|
||||
766
apps/ios/Sources/Design/AgentProTab+Skills.swift
Normal file
766
apps/ios/Sources/Design/AgentProTab+Skills.swift
Normal file
@@ -0,0 +1,766 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var skillsPolicyControls: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.activeAgentName)
|
||||
.font(.headline)
|
||||
Text(self.skillPolicySummary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(
|
||||
value: self.agentSkillFilter == nil ? "all" : "\(self.agentSkillFilter?.count ?? 0)",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Enable All") {
|
||||
Task { await self.enableAllSkills() }
|
||||
}
|
||||
.disabled(self.skillMutationBusy)
|
||||
|
||||
Button("Disable All", role: .destructive) {
|
||||
Task { await self.disableAllSkills() }
|
||||
}
|
||||
.disabled(self.skillMutationBusy)
|
||||
|
||||
Button("Reset") {
|
||||
Task { await self.resetSkillPolicy() }
|
||||
}
|
||||
.disabled(self.skillMutationBusy || self.agentSkillFilter == nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
if let skillMutationStatusText {
|
||||
Text(skillMutationStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
if let skillMutationErrorText {
|
||||
Text(skillMutationErrorText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var skillsFilterField: some View {
|
||||
ProCard(padding: 10, radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search skills", text: self.$skillFilter)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
if !self.skillFilter.isEmpty {
|
||||
Button {
|
||||
self.skillFilter = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Picker("Status", selection: self.$skillStatusFilter) {
|
||||
ForEach(SkillStatusFilter.allCases) { filter in
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var clawHubSearchCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ProIconBadge(systemName: "square.and.arrow.down", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Install Skills")
|
||||
.font(.headline)
|
||||
Text("Search ClawHub and install into this workspace.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.searchClawHubSkills() }
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.clawHubLoading || !self.gatewayConnected)
|
||||
.accessibilityLabel("Search ClawHub")
|
||||
}
|
||||
|
||||
TextField("Search ClawHub", text: self.$clawHubQuery)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.font(.subheadline)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
Task { await self.searchClawHubSkills() }
|
||||
}
|
||||
|
||||
if self.clawHubLoading {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let clawHubErrorText {
|
||||
Text(clawHubErrorText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
if !self.clawHubResults.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
let results = Array(self.clawHubResults.prefix(8))
|
||||
ForEach(Array(results.enumerated()), id: \.element.slug) { index, result in
|
||||
self.clawHubResultRow(result)
|
||||
if index < results.count - 1 {
|
||||
Divider().padding(.leading, 42)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View {
|
||||
let installing = self.clawHubInstallSlug == result.slug
|
||||
return HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(result.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(result.summary ?? result.slug)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.installClawHubSkill(result) }
|
||||
} label: {
|
||||
Image(systemName: installing ? "hourglass" : "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(installing || !self.skillConfigBusyKeys.isEmpty)
|
||||
.accessibilityLabel("Install \(result.displayName)")
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
var skillsList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Installed Skills")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let skills = self.filteredSkills
|
||||
if skills.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "sparkles",
|
||||
title: self.gatewayConnected ? "No skills found" : "Skills unavailable",
|
||||
detail: self.gatewayConnected
|
||||
? "Try a different search or refresh from the gateway."
|
||||
: "Connect a gateway to load workspace skills.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(skills.enumerated()), id: \.element.name) { index, skill in
|
||||
self.skillRow(skill)
|
||||
if index < skills.count - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == self.activeAgentID }) {
|
||||
return self.agentName(for: agent)
|
||||
}
|
||||
return self.activeAgentID
|
||||
}
|
||||
|
||||
var agentSkillFilter: Set<String>? {
|
||||
self.overview?.agentSkillFilter.map { Set($0) }
|
||||
}
|
||||
|
||||
var skillPolicySummary: String {
|
||||
guard self.gatewayConnected else { return "Connect a gateway to edit skills." }
|
||||
guard let filter = self.agentSkillFilter else {
|
||||
return "All available skills are allowed for this agent."
|
||||
}
|
||||
if filter.isEmpty {
|
||||
return "No skills are allowed for this agent."
|
||||
}
|
||||
return "\(filter.count) skills are allowed for this agent."
|
||||
}
|
||||
|
||||
var skillMutationBusy: Bool {
|
||||
!self.skillMutationBusyKeys.isEmpty
|
||||
}
|
||||
|
||||
var filteredSkills: [SkillStatusEntryLite] {
|
||||
let skills = self.overview?.skills?.skills ?? []
|
||||
let filter = self.skillFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return skills
|
||||
.filter { skill in
|
||||
self.matchesSkillStatusFilter(skill)
|
||||
}
|
||||
.filter { skill in
|
||||
guard !filter.isEmpty else { return true }
|
||||
return [
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.source,
|
||||
].compactMap(\.self)
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
.contains(filter)
|
||||
}
|
||||
.sorted(by: self.sortSkills)
|
||||
}
|
||||
|
||||
func matchesSkillStatusFilter(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
switch self.skillStatusFilter {
|
||||
case .all:
|
||||
true
|
||||
case .enabled:
|
||||
self.skillStatus(skill).text == "enabled"
|
||||
case .off:
|
||||
!self.isSkillAllowed(skill) || skill.blockedByAgentFilter == true
|
||||
case .setup:
|
||||
skill.hasMissingRequirements
|
||||
case .blocked:
|
||||
skill.blockedByAllowlist == true
|
||||
}
|
||||
}
|
||||
|
||||
func sortSkills(_ lhs: SkillStatusEntryLite, _ rhs: SkillStatusEntryLite) -> Bool {
|
||||
let lhsEnabled = self.isSkillAllowed(lhs)
|
||||
let rhsEnabled = self.isSkillAllowed(rhs)
|
||||
if lhsEnabled != rhsEnabled { return lhsEnabled && !rhsEnabled }
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
|
||||
func skillRow(_ skill: SkillStatusEntryLite) -> some View {
|
||||
let status = self.skillStatus(skill)
|
||||
let busy = self.skillMutationBusyKeys.contains(skill.name)
|
||||
return HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.isSkillAllowed(skill) ? "checkmark.circle" : "nosign", color: status.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(skill.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
if let missing = skill.missingSummary {
|
||||
Text("Missing: \(missing)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if let install = skill.installSummary {
|
||||
Text("Setup: \(install)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
self.skillToggle(skill, title: status.text)
|
||||
HStack(spacing: 6) {
|
||||
if self.canInstallSkillRequirements(skill) {
|
||||
Button {
|
||||
Task { await self.installSkillRequirements(skill) }
|
||||
} label: {
|
||||
Image(systemName: "wrench.and.screwdriver")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
.accessibilityLabel("Set up \(skill.displayName)")
|
||||
}
|
||||
Button {
|
||||
self.openSkillEditor(skill)
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.accessibilityLabel("Edit \(skill.displayName)")
|
||||
}
|
||||
Text(busy ? "saving" : status.text)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(status.color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
|
||||
func skillToggle(_ skill: SkillStatusEntryLite, title: String) -> some View {
|
||||
Toggle(
|
||||
title,
|
||||
isOn: Binding(
|
||||
get: { self.isSkillAllowed(skill) },
|
||||
set: { enabled in
|
||||
Task { await self.setSkillAllowed(skill, enabled: enabled) }
|
||||
}))
|
||||
.labelsHidden()
|
||||
.disabled(self.skillMutationBusy)
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
|
||||
func isSkillAllowed(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
guard let filter = self.agentSkillFilter else { return true }
|
||||
return filter.contains(skill.name)
|
||||
}
|
||||
|
||||
func isSkillConfigBusy(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
self.skillConfigBusyKeys.contains(skill.effectiveSkillKey)
|
||||
|| self.clawHubInstallSlug != nil
|
||||
}
|
||||
|
||||
func canInstallSkillRequirements(_ skill: SkillStatusEntryLite) -> Bool {
|
||||
skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
&& !skill.missingBins.isEmpty
|
||||
}
|
||||
|
||||
func skillByKey(_ key: String) -> SkillStatusEntryLite? {
|
||||
(self.overview?.skills?.skills ?? []).first { skill in
|
||||
skill.effectiveSkillKey == key || skill.name == key
|
||||
}
|
||||
}
|
||||
|
||||
func openSkillEditor(_ skill: SkillStatusEntryLite) {
|
||||
self.skillEditorSelection = SkillEditorSelection(id: skill.effectiveSkillKey)
|
||||
}
|
||||
|
||||
func skillAPIKeyBinding(for skill: SkillStatusEntryLite) -> Binding<String> {
|
||||
Binding(
|
||||
get: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? "" },
|
||||
set: { self.skillAPIKeyDrafts[skill.effectiveSkillKey] = $0 })
|
||||
}
|
||||
|
||||
var missingSkillEditorSheet: some View {
|
||||
NavigationStack {
|
||||
ContentUnavailableView("Skill unavailable", systemImage: "sparkles")
|
||||
.navigationTitle("Skill")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
self.skillEditorSelection = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorSheet(_ skill: SkillStatusEntryLite) -> some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.skillEditorHeader(skill)
|
||||
self.skillEditorControls(skill)
|
||||
self.skillEditorSetup(skill)
|
||||
self.skillEditorMetadata(skill)
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.navigationTitle(skill.displayName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Close") {
|
||||
self.skillEditorSelection = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skillEditorHeader(_ skill: SkillStatusEntryLite) -> some View {
|
||||
let status = self.skillStatus(skill)
|
||||
return ProCard(radius: AgentLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: skill.isGloballyEnabled ? "checkmark.circle" : "pause.circle",
|
||||
color: status.color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(skill.displayName)
|
||||
.font(.headline)
|
||||
Text(self.normalized(skill.description) ?? self.normalized(skill.source) ?? "Workspace skill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: status.text, color: status.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorControls(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle(
|
||||
"Enabled globally",
|
||||
isOn: Binding(
|
||||
get: { skill.isGloballyEnabled },
|
||||
set: { enabled in
|
||||
Task { await self.updateSkillGlobalEnabled(skill, enabled: enabled) }
|
||||
}))
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
|
||||
if let primaryEnv = skill.primaryEnv, !primaryEnv.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("API key")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
SecureField(primaryEnv, text: self.skillAPIKeyBinding(for: skill))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button {
|
||||
Task { await self.saveSkillAPIKey(skill) }
|
||||
} label: {
|
||||
Label("Save key", systemImage: "key")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
.disabled(self.isSkillConfigBusy(skill))
|
||||
if let homepage = skill.homepageURL {
|
||||
Link("Get key", destination: homepage)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let message = self.skillConfigMessages[skill.effectiveSkillKey] {
|
||||
Text(message.text)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(message.kind == .success ? OpenClawBrand.accent : OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorSetup(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Setup")
|
||||
.font(.headline)
|
||||
if let missing = skill.missingSummary {
|
||||
Text("Missing: \(missing)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
} else {
|
||||
Text("No missing requirements reported.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let install = skill.install?.first {
|
||||
Button {
|
||||
Task { await self.installSkillRequirements(skill) }
|
||||
} label: {
|
||||
Label(install.label, systemImage: "wrench.and.screwdriver")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(self.isSkillConfigBusy(skill) || install.id == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func skillEditorMetadata(_ skill: SkillStatusEntryLite) -> some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.detailMetric(label: "Key", value: skill.effectiveSkillKey)
|
||||
self.detailMetric(label: "Source", value: self.normalized(skill.source) ?? "unknown")
|
||||
if let filePath = self.normalized(skill.filePath) {
|
||||
Text(filePath)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func setSkillAllowed(_ skill: SkillStatusEntryLite, enabled: Bool) async {
|
||||
let allNames = self.allSkillNames
|
||||
guard !allNames.isEmpty else { return }
|
||||
let base = self.agentSkillFilter ?? Set(allNames)
|
||||
var next = base
|
||||
if enabled {
|
||||
next.insert(skill.name)
|
||||
} else {
|
||||
next.remove(skill.name)
|
||||
}
|
||||
await self.patchAgentSkills(Array(next).sorted(), busyKey: skill.name)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func enableAllSkills() async {
|
||||
let allNames = self.allSkillNames
|
||||
guard !allNames.isEmpty else { return }
|
||||
await self.patchAgentSkills(allNames, busyKey: "__all__")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func disableAllSkills() async {
|
||||
await self.patchAgentSkills([], busyKey: "__all__")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resetSkillPolicy() async {
|
||||
await self.patchAgentSkills(nil, busyKey: "__all__")
|
||||
}
|
||||
|
||||
var allSkillNames: [String] {
|
||||
(self.overview?.skills?.skills ?? [])
|
||||
.map(\.name)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
.sorted()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func patchAgentSkills(_ skills: [String]?, busyKey: String) async {
|
||||
guard self.gatewayConnected else { return }
|
||||
self.skillMutationBusyKeys.insert(busyKey)
|
||||
self.skillMutationErrorText = nil
|
||||
self.skillMutationStatusText = nil
|
||||
defer { self.skillMutationBusyKeys.remove(busyKey) }
|
||||
|
||||
do {
|
||||
let config = try await self.requestConfigSnapshot()
|
||||
guard let baseHash = self.normalized(config.hash) else {
|
||||
throw SkillMutationError.missingConfigHash
|
||||
}
|
||||
if skills == nil,
|
||||
config.agentConfig(id: self.activeAgentID) == nil
|
||||
{
|
||||
self.skillMutationStatusText = "This agent already inherits the default skill policy."
|
||||
return
|
||||
}
|
||||
|
||||
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
|
||||
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
_ = try await self.appModel.operatorSession.request(
|
||||
method: "config.patch",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 20)
|
||||
self.skillMutationStatusText = skills == nil ? "Skill policy reset." : "Skill policy saved."
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.skillMutationErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func updateSkillGlobalEnabled(_ skill: SkillStatusEntryLite, enabled: Bool) async {
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, enabled: enabled)
|
||||
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
|
||||
return enabled ? "Skill enabled." : "Skill disabled."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func saveSkillAPIKey(_ skill: SkillStatusEntryLite) async {
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let apiKey = self.skillAPIKeyDrafts[skill.effectiveSkillKey] ?? ""
|
||||
let params = SkillUpdateParams(skillKey: skill.effectiveSkillKey, apiKey: apiKey)
|
||||
_ = try await self.requestGateway(method: "skills.update", params: params, timeoutSeconds: 20)
|
||||
self.skillAPIKeyDrafts[skill.effectiveSkillKey] = ""
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "API key cleared."
|
||||
: "API key saved."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func installSkillRequirements(_ skill: SkillStatusEntryLite) async {
|
||||
guard let installId = skill.install?.first?.id?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!installId.isEmpty
|
||||
else { return }
|
||||
await self.runSkillConfigMutation(skill) {
|
||||
let params = SkillInstallParams(name: skill.name, installId: installId, timeoutMs: 120_000)
|
||||
let data = try await self.requestGateway(
|
||||
method: "skills.install",
|
||||
params: params,
|
||||
timeoutSeconds: 125)
|
||||
return (try? JSONDecoder().decode(SkillInstallResultLite.self, from: data).message) ?? "Installed."
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
|
||||
guard self.gatewayConnected else { return }
|
||||
self.clawHubInstallSlug = result.slug
|
||||
self.clawHubErrorText = nil
|
||||
defer { self.clawHubInstallSlug = nil }
|
||||
do {
|
||||
let params = ClawHubInstallParams(slug: result.slug)
|
||||
_ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125)
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.clawHubErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func searchClawHubSkills() async {
|
||||
guard self.gatewayConnected else { return }
|
||||
self.clawHubLoading = true
|
||||
self.clawHubErrorText = nil
|
||||
defer { self.clawHubLoading = false }
|
||||
do {
|
||||
let query = self.clawHubQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let params = ClawHubSearchParams(query: query.isEmpty ? nil : query, limit: 20)
|
||||
let data = try await self.requestGateway(method: "skills.search", params: params, timeoutSeconds: 20)
|
||||
self.clawHubResults = try JSONDecoder().decode(ClawHubSearchResponseLite.self, from: data).results
|
||||
} catch {
|
||||
self.clawHubErrorText = Self.skillMutationMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runSkillConfigMutation(
|
||||
_ skill: SkillStatusEntryLite,
|
||||
action: () async throws -> String) async
|
||||
{
|
||||
let key = skill.effectiveSkillKey
|
||||
self.skillConfigBusyKeys.insert(key)
|
||||
self.skillConfigMessages[key] = nil
|
||||
defer { self.skillConfigBusyKeys.remove(key) }
|
||||
|
||||
do {
|
||||
let message = try await action()
|
||||
self.skillConfigMessages[key] = SkillEditorMessage(kind: .success, text: message)
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await self.refreshOverview(force: true)
|
||||
} catch {
|
||||
self.skillConfigMessages[key] = SkillEditorMessage(
|
||||
kind: .error,
|
||||
text: Self.skillMutationMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
func requestGateway(
|
||||
method: String,
|
||||
params: some Encodable,
|
||||
timeoutSeconds: Int) async throws -> Data
|
||||
{
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
return try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
func requestConfigSnapshot() async throws -> ConfigSnapshotLite {
|
||||
let data = try await self.appModel.operatorSession.request(
|
||||
method: "config.get",
|
||||
paramsJSON: "{}",
|
||||
timeoutSeconds: 12)
|
||||
return try JSONDecoder().decode(ConfigSnapshotLite.self, from: data)
|
||||
}
|
||||
|
||||
static func agentSkillsPatchRaw(agentId: String, skills: [String]?) throws -> String {
|
||||
let skillValue: Any = skills ?? NSNull()
|
||||
let patch: [String: Any] = [
|
||||
"agents": [
|
||||
"list": [
|
||||
[
|
||||
"id": agentId,
|
||||
"skills": skillValue,
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: patch, options: [.sortedKeys])
|
||||
guard let raw = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
static func skillMutationMessage(_ error: Error) -> String {
|
||||
if let gatewayError = error as? GatewayResponseError {
|
||||
let lower = gatewayError.message.lowercased()
|
||||
if lower.contains("operator.admin") || lower.contains("unauthorized") {
|
||||
return "This gateway connection cannot edit config yet. Reconnect with admin scope."
|
||||
}
|
||||
return gatewayError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
|
||||
func skillStatus(_ skill: SkillStatusEntryLite) -> (text: String, color: Color) {
|
||||
if !self.isSkillAllowed(skill) {
|
||||
return ("off", .secondary)
|
||||
}
|
||||
if skill.blockedByAllowlist == true {
|
||||
return ("blocked", .secondary)
|
||||
}
|
||||
if skill.blockedByAgentFilter == true {
|
||||
return ("off", .secondary)
|
||||
}
|
||||
if skill.disabled == true {
|
||||
return ("disabled", .secondary)
|
||||
}
|
||||
if skill.hasMissingRequirements {
|
||||
return ("setup", OpenClawBrand.warn)
|
||||
}
|
||||
return ("enabled", OpenClawBrand.accent)
|
||||
}
|
||||
}
|
||||
81
apps/ios/Sources/Design/AgentProTab+Usage.swift
Normal file
81
apps/ios/Sources/Design/AgentProTab+Usage.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
var usageTotalsCard: some View {
|
||||
ProCard(radius: AgentLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Totals")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
ProValuePill(value: "\(self.overview?.usage?.days ?? 31)d", color: OpenClawBrand.accent)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
self.detailMetric(label: "Cost", value: self.usageValue)
|
||||
self.detailMetric(label: "Tokens", value: self.usageTokenValue)
|
||||
self.detailMetric(label: "Cache", value: self.usageCacheValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var usageTokenValue: String {
|
||||
guard let tokens = self.overview?.usage?.totalTokens else { return "0" }
|
||||
return Self.compactNumber(tokens)
|
||||
}
|
||||
|
||||
var usageCacheValue: String {
|
||||
guard let cacheStatus = self.normalized(self.overview?.usage?.cacheStatus?["status"]?.value as? String) else {
|
||||
return "n/a"
|
||||
}
|
||||
return cacheStatus
|
||||
}
|
||||
|
||||
var usageDailyList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Daily")
|
||||
ProCard(padding: 0, radius: AgentLayout.cardRadius) {
|
||||
let days = self.overview?.usage?.daily ?? []
|
||||
if days.isEmpty {
|
||||
self.emptyDetailRow(
|
||||
icon: "chart.bar",
|
||||
title: "No daily usage yet",
|
||||
detail: "The gateway returned totals without daily session cost rows.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(days.prefix(14).enumerated()), id: \.element.date) { index, day in
|
||||
self.usageDayRow(day)
|
||||
if index < min(days.count, 14) - 1 {
|
||||
Divider().padding(.leading, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func usageDayRow(_ day: CostUsageDailyEntryLite) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "calendar", color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(day.date)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("\(Self.compactNumber(day.totalTokens ?? 0)) tokens")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(Self.currency(day.totalCost ?? 0))
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
}
|
||||
163
apps/ios/Sources/Design/AgentProTab.swift
Normal file
163
apps/ios/Sources/Design/AgentProTab.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@State var overviewRefreshNonce: Int = 0
|
||||
@State var agentRosterFilter: AgentRosterFilter = .all
|
||||
@State var agentSearchPresented = false
|
||||
@State var agentSearchText = ""
|
||||
@State var skillFilter: String = ""
|
||||
@State var skillStatusFilter: SkillStatusFilter = .all
|
||||
@State var skillMutationBusyKeys: Set<String> = []
|
||||
@State var skillMutationErrorText: String?
|
||||
@State var skillMutationStatusText: String?
|
||||
@State var skillConfigBusyKeys: Set<String> = []
|
||||
@State var skillConfigMessages: [String: SkillEditorMessage] = [:]
|
||||
@State var skillAPIKeyDrafts: [String: String] = [:]
|
||||
@State var skillEditorSelection: SkillEditorSelection?
|
||||
@State var clawHubQuery: String = ""
|
||||
@State var clawHubResults: [ClawHubSearchResultLite] = []
|
||||
@State var clawHubLoading: Bool = false
|
||||
@State var clawHubErrorText: String?
|
||||
@State var clawHubInstallSlug: String?
|
||||
@State var cronActionBusyIDs: Set<String> = []
|
||||
@State var cronActionStatusText: String?
|
||||
|
||||
enum AgentRoute: Hashable {
|
||||
case skills
|
||||
case nodes
|
||||
case cron
|
||||
case usage
|
||||
case dreaming
|
||||
}
|
||||
|
||||
enum SkillStatusFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case enabled
|
||||
case off
|
||||
case setup
|
||||
case blocked
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .enabled: "Enabled"
|
||||
case .off: "Off"
|
||||
case .setup: "Setup"
|
||||
case .blocked: "Blocked"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentRosterFilter: String, CaseIterable, Identifiable {
|
||||
case all
|
||||
case online
|
||||
case busy
|
||||
case idle
|
||||
|
||||
var id: Self {
|
||||
self
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "All"
|
||||
case .online: "Online"
|
||||
case .busy: "Busy"
|
||||
case .idle: "Idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AgentLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let filterHeight: CGFloat = 34
|
||||
static let rowMinHeight: CGFloat = 104
|
||||
static let metricTileHeight: CGFloat = 94
|
||||
static let actionButtonSize: CGFloat = 34
|
||||
}
|
||||
|
||||
enum AgentRosterState: Equatable {
|
||||
case online
|
||||
case busy
|
||||
case idle
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .online: "Online"
|
||||
case .busy: "Busy"
|
||||
case .idle: "Idle"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .online: OpenClawBrand.ok
|
||||
case .busy: OpenClawBrand.warn
|
||||
case .idle: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SkillEditorSelection: Identifiable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
struct SkillEditorMessage {
|
||||
let kind: Kind
|
||||
let text: String
|
||||
|
||||
enum Kind {
|
||||
case success
|
||||
case error
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.rosterHeader
|
||||
self.agentFilters
|
||||
self.agentsSection
|
||||
self.operationsSection
|
||||
self.dreamingSection
|
||||
self.cronSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: AgentRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.task(id: self.overviewTaskID) {
|
||||
await self.refreshOverview(force: false)
|
||||
}
|
||||
.sheet(item: self.$skillEditorSelection) { selection in
|
||||
if let skill = self.skillByKey(selection.id) {
|
||||
self.skillEditorSheet(skill)
|
||||
} else {
|
||||
self.missingSkillEditorSheet
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
191
apps/ios/Sources/Design/ChatProTab.swift
Normal file
191
apps/ios/Sources/Design/ChatProTab.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct ChatProTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(spacing: 0) {
|
||||
self.header
|
||||
if let viewModel {
|
||||
OpenClawChatView(
|
||||
viewModel: viewModel,
|
||||
drawsBackground: false,
|
||||
showsSessionSwitcher: false,
|
||||
userAccent: self.chatUserAccent,
|
||||
assistantName: self.agentDisplayName,
|
||||
assistantAvatarText: self.agentBadge,
|
||||
assistantAvatarTint: OpenClawBrand.accent,
|
||||
showsAssistantAvatars: false,
|
||||
composerChrome: .clean,
|
||||
messagePlaceholder: "Message \(self.agentDisplayName)...",
|
||||
talkControl: self.talkControl)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
} else {
|
||||
ProCard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Chat is preparing")
|
||||
.font(.headline)
|
||||
Text("The operator session will attach when the gateway is ready.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.task {
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
.onChange(of: self.appModel.chatSessionKey) { _, _ in
|
||||
self.syncChatViewModel()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 11) {
|
||||
Text(self.agentBadge)
|
||||
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent,
|
||||
OpenClawBrand.accentHot,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(self.agentDisplayName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text("AI Assistant")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.connectionPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
private func syncChatViewModel() {
|
||||
let sessionKey = self.appModel.chatSessionKey
|
||||
guard let viewModel else {
|
||||
self.viewModel = OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: IOSGatewayChatTransport(gateway: self.appModel.operatorSession),
|
||||
onSessionChanged: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
},
|
||||
diagnosticsLog: { message in
|
||||
GatewayDiagnostics.log(message)
|
||||
})
|
||||
return
|
||||
}
|
||||
guard viewModel.sessionKey != sessionKey else { return }
|
||||
viewModel.switchSession(to: sessionKey)
|
||||
}
|
||||
|
||||
private var talkControl: OpenClawChatTalkControl {
|
||||
OpenClawChatTalkControl(
|
||||
isEnabled: self.appModel.talkMode.isEnabled,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isGatewayConnected: self.appModel.talkMode.isGatewayConnected,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
providerLabel: self.appModel.talkMode.gatewayTalkProviderLabel,
|
||||
toggle: { sessionKey in
|
||||
self.appModel.focusChatSession(sessionKey)
|
||||
self.appModel.setTalkEnabled(!self.appModel.talkMode.isEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
private var activeAgentID: String {
|
||||
self.normalized(self.appModel.selectedAgentId)
|
||||
?? self.normalized(self.appModel.gatewayDefaultAgentId)
|
||||
?? "main"
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
Text(self.gatewayConnected ? "Connected" : "Connecting")
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
private var activeAgent: AgentSummary? {
|
||||
self.appModel.gatewayAgents.first { $0.id == self.activeAgentID }
|
||||
}
|
||||
|
||||
private var agentDisplayName: String {
|
||||
self.normalized(self.activeAgent?.name) ?? self.appModel.activeAgentName
|
||||
}
|
||||
|
||||
private var agentBadge: String {
|
||||
if let identity = self.activeAgent?.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = self.normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.agentDisplayName
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
281
apps/ios/Sources/Design/CommandCenterSupport.swift
Normal file
281
apps/ios/Sources/Design/CommandCenterSupport.swift
Normal file
@@ -0,0 +1,281 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CommandPanel<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent = false
|
||||
var padding: CGFloat = 13
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
tint: Color? = nil,
|
||||
isProminent: Bool = false,
|
||||
padding: CGFloat = 13,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.tint = tint
|
||||
self.isProminent = isProminent
|
||||
self.padding = padding
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ProCard(
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent,
|
||||
padding: self.padding,
|
||||
radius: 12)
|
||||
{
|
||||
self.content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandControlBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.34),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
.frame(height: 260)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
|
||||
private var darkColors: [Color] {
|
||||
[
|
||||
Color(red: 12 / 255, green: 13 / 255, blue: 15 / 255),
|
||||
Color(red: 7 / 255, green: 8 / 255, blue: 10 / 255),
|
||||
Color(red: 4 / 255, green: 5 / 255, blue: 6 / 255),
|
||||
]
|
||||
}
|
||||
|
||||
private var lightColors: [Color] {
|
||||
[
|
||||
Color(red: 247 / 255, green: 248 / 255, blue: 249 / 255),
|
||||
Color(red: 251 / 255, green: 252 / 255, blue: 253 / 255),
|
||||
.white,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionRow: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(self.item.color.opacity(0.12))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.item.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
Spacer(minLength: 6)
|
||||
Text(self.item.trailing)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(self.item.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 6)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 68)
|
||||
}
|
||||
Text(self.progressLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.lineLimit(1)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var progressLabel: String {
|
||||
guard let progress = self.item.progress else {
|
||||
return self.item.state
|
||||
}
|
||||
if self.item.state == "offline" || self.item.state == "off" || self.item.state == "idle" {
|
||||
return self.item.state
|
||||
}
|
||||
return "\(Int((progress * 100).rounded()))%"
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandApprovalRow: View {
|
||||
let item: CommandCenterTab.ApprovalItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.item.icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(self.item.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.item.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.item.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.item.priority)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(self.item.color)
|
||||
.padding(.horizontal, 9)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.item.color.opacity(0.10))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 7)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandEmptyStateRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: self.icon)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.ok)
|
||||
.frame(width: 30, height: 30)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(OpenClawBrand.ok.opacity(0.10))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandTaskRow: View {
|
||||
let item: CommandCenterTab.WorkItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 6) {
|
||||
Text(self.item.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.80)
|
||||
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
|
||||
Text(self.item.detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.frame(width: 64, alignment: .leading)
|
||||
if let progress = self.item.progress {
|
||||
ProProgressBar(progress: progress, color: self.item.color)
|
||||
.frame(width: 56)
|
||||
}
|
||||
Text(self.item.state)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandLiveActivityRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
ProStatusDot(color: self.color)
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(self.value)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.08))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
692
apps/ios/Sources/Design/CommandCenterTab.swift
Normal file
692
apps/ios/Sources/Design/CommandCenterTab.swift
Normal file
@@ -0,0 +1,692 @@
|
||||
import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var activeChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
enum WorkRoute {
|
||||
case chat(String?)
|
||||
case settings
|
||||
}
|
||||
|
||||
struct WorkItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
let progress: Double?
|
||||
let route: WorkRoute
|
||||
}
|
||||
|
||||
struct ApprovalItem: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let priority: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
self.pendingApprovals
|
||||
self.activeTasks
|
||||
self.liveActivity
|
||||
self.startWorkAction
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.task(id: self.activeSessionsRefreshID) {
|
||||
await self.refreshActiveSessionsIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
Text("OpenClaw")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var commandAmbientOverlay: some View {
|
||||
Group {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.05),
|
||||
Color.clear,
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayCard: some View {
|
||||
CommandPanel(isProminent: true, padding: 12) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
icon: "network",
|
||||
title: "Connection",
|
||||
value: self.gatewayConnected ? "Online" : "Offline",
|
||||
color: self.gatewayStatusColor)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
icon: "server.rack",
|
||||
title: "Address",
|
||||
value: self.gatewayAddressText,
|
||||
color: OpenClawBrand.accent)
|
||||
Divider().frame(height: 38)
|
||||
self.gatewayFact(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayAgentCountText,
|
||||
color: OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.colorScheme == .dark ? Color.black.opacity(0.16) : Color.black.opacity(0.026))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func gatewayFact(icon: String, title: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(color)
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(title == "Connection" ? color : .primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
private var pendingApprovals: some View {
|
||||
self.pendingApprovalsContent
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var pendingApprovalsContent: some View {
|
||||
CommandPanel(
|
||||
tint: self.pendingApproval == nil ? nil : OpenClawBrand.warn,
|
||||
isProminent: self.pendingApproval != nil,
|
||||
padding: self.pendingApproval == nil ? 11 : 13)
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.cardHeader(
|
||||
title: "Pending approvals",
|
||||
value: self.pendingApproval == nil ? nil : "Review requests ›",
|
||||
color: OpenClawBrand.accentHot,
|
||||
badgeValue: self.approvalItems.isEmpty ? nil : "\(self.approvalItems.count)")
|
||||
|
||||
if self.approvalItems.isEmpty {
|
||||
CommandEmptyStateRow(
|
||||
icon: "checkmark.shield.fill",
|
||||
title: "No approvals waiting",
|
||||
detail: self
|
||||
.gatewayConnected ? "Gateway requests will appear here." : "Connect to the gateway.")
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.approvalItems.enumerated()), id: \.element.id) { index, item in
|
||||
CommandApprovalRow(item: item)
|
||||
if index < self.approvalItems.count - 1 {
|
||||
Divider().padding(.leading, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.approvalRowsFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(
|
||||
Color.primary.opacity(self.colorScheme == .dark ? 0.08 : 0.04),
|
||||
lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let pendingApproval {
|
||||
HStack(spacing: 8) {
|
||||
Button {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once") }
|
||||
} label: {
|
||||
Label("Allow", systemImage: "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
if pendingApproval.allowsAllowAlways {
|
||||
Button {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
||||
}
|
||||
} label: {
|
||||
Label("Always", systemImage: "checkmark.shield")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
Task { await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny") }
|
||||
} label: {
|
||||
Label("Deny", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.appModel.pendingExecApprovalPromptResolving)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeTasks: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Active sessions",
|
||||
value: self.activeSessionsSummaryText,
|
||||
color: .secondary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ForEach(self.visibleActiveSessionRows) { item in
|
||||
Button {
|
||||
self.open(item.route)
|
||||
} label: {
|
||||
CommandSessionRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var liveActivity: some View {
|
||||
CommandPanel(padding: 0) {
|
||||
VStack(spacing: 0) {
|
||||
self.cardHeader(
|
||||
title: "Live activity",
|
||||
value: nil,
|
||||
color: OpenClawBrand.accent)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
|
||||
CommandLiveActivityRow(
|
||||
title: self.liveActivityTitle,
|
||||
value: self.liveActivityValue,
|
||||
color: self.liveActivityColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var startWorkAction: some View {
|
||||
CommandPanel(tint: OpenClawBrand.accent, isProminent: true, padding: 9) {
|
||||
Button(action: self.openChat) {
|
||||
Label("Start work", systemImage: "play.fill")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.fill(LinearGradient(
|
||||
colors: [OpenClawBrand.accentHot, OpenClawBrand.accent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.shadow(color: OpenClawBrand.accentHot.opacity(0.34), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
title: String,
|
||||
value: String?,
|
||||
color: Color,
|
||||
icon: String? = nil,
|
||||
badgeValue: String? = nil,
|
||||
action: (() -> Void)? = nil) -> some View
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.bold))
|
||||
if let badgeValue {
|
||||
Text(badgeValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(OpenClawBrand.accentHot, in: Capsule())
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if let value {
|
||||
if let action {
|
||||
Button(value, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
Text(value)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Healthy" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = status.lowercased()
|
||||
if lowercased.contains("approval") { return "Approval" }
|
||||
if lowercased.contains("reconnect") { return "Reconnecting" }
|
||||
if lowercased.contains("connect") { return "Connecting" }
|
||||
if lowercased.contains("idle") { return "Idle" }
|
||||
return "Offline"
|
||||
}
|
||||
|
||||
private var gatewayStatusColor: Color {
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
private var gatewayAddressText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "Unknown"
|
||||
}
|
||||
|
||||
private var gatewayAgentCountText: String {
|
||||
guard self.gatewayConnected else { return "—" }
|
||||
return "\(self.appModel.gatewayAgents.count)"
|
||||
}
|
||||
|
||||
private var activeSessionsSummaryText: String {
|
||||
let count = self.activeSessionRows.count
|
||||
if count == 0 {
|
||||
return self.gatewayConnected ? "No sessions" : "Offline"
|
||||
}
|
||||
if self.sessionWorkItems.isEmpty {
|
||||
return self.gatewayConnected ? "\(count) ready" : "Offline"
|
||||
}
|
||||
return "\(count) \(count == 1 ? "session" : "sessions")"
|
||||
}
|
||||
|
||||
private var approvalItems: [ApprovalItem] {
|
||||
if let pendingApproval {
|
||||
return [
|
||||
ApprovalItem(
|
||||
id: "pending-real",
|
||||
icon: "terminal.fill",
|
||||
title: pendingApproval.commandPreview ?? "Review gateway action",
|
||||
detail: "Agent: \(self.appModel.activeAgentName)",
|
||||
priority: self.appModel.pendingExecApprovalPromptResolving ? "Resolving" : "High",
|
||||
color: OpenClawBrand.danger),
|
||||
ApprovalItem(
|
||||
id: "pending-context",
|
||||
icon: "doc.text.fill",
|
||||
title: pendingApproval.allowsAllowAlways ? "Permission can be saved" : "One-time approval",
|
||||
detail: "Gateway request",
|
||||
priority: pendingApproval.allowsAllowAlways ? "Medium" : "Review",
|
||||
color: OpenClawBrand.warn),
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private var approvalRowsFill: Color {
|
||||
self.colorScheme == .dark ? Color.black.opacity(0.12) : Color.black.opacity(0.022)
|
||||
}
|
||||
|
||||
private var activeSessionRows: [WorkItem] {
|
||||
self.sessionItems
|
||||
}
|
||||
|
||||
private var visibleActiveSessionRows: [WorkItem] {
|
||||
Array(self.activeSessionRows.prefix(3))
|
||||
}
|
||||
|
||||
private var liveActivityTitle: String {
|
||||
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }) {
|
||||
return "\(Self.sessionTitle(session)) updated"
|
||||
}
|
||||
if self.pendingApproval != nil {
|
||||
return "Approval waiting"
|
||||
}
|
||||
return self.gatewayConnected ? "Gateway connected" : self.gatewayStateText
|
||||
}
|
||||
|
||||
private var liveActivityValue: String {
|
||||
if let session = self.activeChatSessions.first(where: { !Self.isHiddenInternalSession($0.key) }),
|
||||
let updatedAt = session.updatedAt,
|
||||
updatedAt > 0
|
||||
{
|
||||
return Self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
if self.pendingApproval != nil {
|
||||
return "review"
|
||||
}
|
||||
return self.gatewayConnected ? self.gatewayAddressText : self.gatewayDisplayStatusValue
|
||||
}
|
||||
|
||||
private var liveActivityColor: Color {
|
||||
if self.pendingApproval != nil { return OpenClawBrand.warn }
|
||||
return self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
}
|
||||
|
||||
private var gatewayDisplayStatusValue: String {
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? self.gatewayStateText : status
|
||||
}
|
||||
|
||||
private var activeSessionsRefreshID: String {
|
||||
[
|
||||
self.appModel.isOperatorGatewayConnected ? "connected" : "offline",
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var sessionItems: [WorkItem] {
|
||||
let liveItems = self.sessionWorkItems
|
||||
if !liveItems.isEmpty { return liveItems }
|
||||
return self.defaultSessionItems
|
||||
}
|
||||
|
||||
private var sessionWorkItems: [WorkItem] {
|
||||
let currentSessionKey = self.appModel.chatSessionKey
|
||||
return self.activeChatSessions
|
||||
.filter { !Self.isHiddenInternalSession($0.key) }
|
||||
.prefix(4)
|
||||
.map { session in
|
||||
let isCurrent = session.key == currentSessionKey
|
||||
return WorkItem(
|
||||
id: "chat-session-\(session.key)",
|
||||
icon: isCurrent ? "bubble.left.and.text.bubble.right.fill" : "bubble.left.fill",
|
||||
title: Self.sessionTitle(session),
|
||||
detail: Self.sessionDetail(session),
|
||||
state: isCurrent ? "current" : "recent",
|
||||
trailing: "chat",
|
||||
color: isCurrent ? OpenClawBrand.accent : OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat(session.key))
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultSessionItems: [WorkItem] {
|
||||
[
|
||||
WorkItem(
|
||||
id: "main-chat",
|
||||
icon: "bubble.left.and.text.bubble.right.fill",
|
||||
title: "Main chat",
|
||||
detail: self.appModel.activeAgentName,
|
||||
state: self.gatewayConnected ? "ready" : "offline",
|
||||
trailing: "session",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .chat(self.appModel.chatSessionKey)),
|
||||
WorkItem(
|
||||
id: "talk-mode",
|
||||
icon: "waveform",
|
||||
title: "Talk",
|
||||
detail: self.appModel.talkMode.statusText,
|
||||
state: self.appModel.talkMode.isEnabled ? "active" : "off",
|
||||
trailing: "voice",
|
||||
color: self.appModel.talkMode.isEnabled ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
WorkItem(
|
||||
id: "device-capture",
|
||||
icon: self.appModel.screenRecordActive ? "record.circle.fill" : "display",
|
||||
title: "Device capture",
|
||||
detail: self.appModel.screenRecordActive ? "Screen capture is active" : "Screen and device tools",
|
||||
state: self.appModel.screenRecordActive ? "running" : "idle",
|
||||
trailing: "device",
|
||||
color: self.appModel.screenRecordActive ? OpenClawBrand.warn : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
WorkItem(
|
||||
id: "agent-roster",
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
detail: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count) available" : "Roster unavailable",
|
||||
state: self.gatewayConnected ? "online" : "offline",
|
||||
trailing: "gateway",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
progress: nil,
|
||||
route: .settings),
|
||||
]
|
||||
}
|
||||
|
||||
private func open(_ route: WorkRoute) {
|
||||
switch route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshActiveSessionsIfNeeded() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.appModel.isOperatorGatewayConnected else {
|
||||
if !self.activeChatSessions.isEmpty {
|
||||
self.activeChatSessions = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let transport = IOSGatewayChatTransport(gateway: appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: 12)
|
||||
self.activeChatSessions = Self.sessionChoices(
|
||||
response.sessions,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
} catch {
|
||||
self.activeChatSessions = []
|
||||
}
|
||||
}
|
||||
|
||||
private static func sessionChoices(
|
||||
_ sessions: [OpenClawChatSessionEntry],
|
||||
currentSessionKey: String) -> [OpenClawChatSessionEntry]
|
||||
{
|
||||
let sorted = sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
var result: [OpenClawChatSessionEntry] = []
|
||||
var included = Set<String>()
|
||||
|
||||
if let current = sorted.first(where: { $0.key == currentSessionKey }) {
|
||||
result.append(current)
|
||||
included.insert(current.key)
|
||||
}
|
||||
|
||||
for session in sorted {
|
||||
guard !included.contains(session.key) else { continue }
|
||||
guard !Self.isHiddenInternalSession(session.key) else { continue }
|
||||
result.append(session)
|
||||
included.insert(session.key)
|
||||
if result.count >= 4 { break }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static func sessionTitle(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let title = redactedSessionTitle(for: session.key) {
|
||||
return title
|
||||
}
|
||||
|
||||
let displayName = session.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let displayName, !displayName.isEmpty {
|
||||
return Self.redactedSessionTitle(for: displayName) ?? displayName
|
||||
}
|
||||
let subject = session.subject?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let subject, !subject.isEmpty {
|
||||
return Self.redactedSessionTitle(for: subject) ?? subject
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
private static func redactedSessionTitle(for key: String) -> String? {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lowercased = trimmed.lowercased()
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if lowercased.contains(":ios-") {
|
||||
return "iOS chat"
|
||||
}
|
||||
if lowercased.hasPrefix("telegram:") {
|
||||
return "Telegram chat"
|
||||
}
|
||||
if lowercased.hasPrefix("user:+") {
|
||||
return "Direct chat"
|
||||
}
|
||||
if lowercased.hasPrefix("cron:") {
|
||||
return Self.humanizedSessionKey(String(trimmed.dropFirst("cron:".count)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func humanizedSessionKey(_ key: String) -> String? {
|
||||
let words = key
|
||||
.replacingOccurrences(of: "_", with: "-")
|
||||
.split(separator: "-")
|
||||
.map(String.init)
|
||||
.filter { !$0.isEmpty }
|
||||
guard !words.isEmpty else { return nil }
|
||||
|
||||
return words
|
||||
.map { word in
|
||||
switch word.lowercased() {
|
||||
case "ai", "api", "ios", "qmd", "url":
|
||||
word.uppercased()
|
||||
default:
|
||||
word.prefix(1).uppercased() + String(word.dropFirst())
|
||||
}
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func sessionDetail(_ session: OpenClawChatSessionEntry) -> String {
|
||||
if let updatedAt = session.updatedAt, updatedAt > 0 {
|
||||
return self.relativeTimeText(forMilliseconds: updatedAt)
|
||||
}
|
||||
return session.key
|
||||
}
|
||||
|
||||
private static func relativeTimeText(forMilliseconds milliseconds: Double) -> String {
|
||||
let date = Date(timeIntervalSince1970: milliseconds / 1000)
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.dateTimeStyle = .numeric
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: .now)
|
||||
}
|
||||
|
||||
private static func isHiddenInternalSession(_ key: String) -> Bool {
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
|
||||
}
|
||||
|
||||
private var gatewaySubtitle: String {
|
||||
if let server = normalized(appModel.gatewayServerName) {
|
||||
return "\(self.appModel.activeAgentName) on \(server)"
|
||||
}
|
||||
if let address = normalized(appModel.gatewayRemoteAddress) {
|
||||
return "\(self.appModel.activeAgentName) via \(address)"
|
||||
}
|
||||
return self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private var pendingApproval: NodeAppModel.ExecApprovalPrompt? {
|
||||
self.appModel.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
Self.normalized(value)
|
||||
}
|
||||
|
||||
private static func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
145
apps/ios/Sources/Design/OpenClawBrand.swift
Normal file
145
apps/ios/Sources/Design/OpenClawBrand.swift
Normal file
@@ -0,0 +1,145 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppAppearancePreference: String, CaseIterable, Identifiable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
static let storageKey = "appearance.preference"
|
||||
|
||||
static var launchArgumentPreference: AppAppearancePreference? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-appearance") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
return AppAppearancePreference(rawValue: arguments[valueIndex].lowercased())
|
||||
}
|
||||
|
||||
var id: String {
|
||||
self.rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: "System"
|
||||
case .light: "Light"
|
||||
case .dark: "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: nil
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .system: .unspecified
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OpenClawBrand {
|
||||
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
|
||||
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
|
||||
static let lightCanvasBottom = Color.white
|
||||
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
|
||||
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
|
||||
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
|
||||
|
||||
static let accent = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
|
||||
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
|
||||
})
|
||||
static let accentHot = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
|
||||
: UIColor(red: 204 / 255.0, green: 75 / 255.0, blue: 69 / 255.0, alpha: 1)
|
||||
})
|
||||
static let danger = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 252 / 255.0, green: 165 / 255.0, blue: 165 / 255.0, alpha: 1)
|
||||
: UIColor(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0, alpha: 1)
|
||||
})
|
||||
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
|
||||
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
|
||||
static let graphite = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)
|
||||
: UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
|
||||
})
|
||||
static let graphiteElevated = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
|
||||
: UIColor.white
|
||||
})
|
||||
static let graphiteSoft = Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
|
||||
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
|
||||
})
|
||||
|
||||
static var sheetBackground: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphite,
|
||||
graphiteElevated.opacity(0.96),
|
||||
Color(uiColor: .systemBackground),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static var toolbarChrome: LinearGradient {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
graphiteElevated.opacity(0.92),
|
||||
graphite.opacity(0.78),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
|
||||
static func glassFill(brighten: Bool) -> Color {
|
||||
Color.black.opacity(brighten ? 0.10 : 0.22)
|
||||
}
|
||||
|
||||
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
|
||||
if active {
|
||||
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
|
||||
}
|
||||
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
|
||||
}
|
||||
|
||||
static func formSectionHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
|
||||
colorScheme == .dark
|
||||
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
|
||||
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func openClawSheetChrome() -> some View {
|
||||
self
|
||||
.tint(OpenClawBrand.accent)
|
||||
.background {
|
||||
OpenClawBrand.sheetBackground
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
577
apps/ios/Sources/Design/OpenClawProComponents.swift
Normal file
577
apps/ios/Sources/Design/OpenClawProComponents.swift
Normal file
@@ -0,0 +1,577 @@
|
||||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 20
|
||||
static let cardRadius: CGFloat = 14
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 22
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
LinearGradient(
|
||||
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent.opacity(0.05),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading)
|
||||
.frame(height: 260)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSectionHeader: View {
|
||||
let title: String
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
var uppercase = true
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(self.uppercase ? .uppercase : nil)
|
||||
Spacer()
|
||||
if let actionTitle {
|
||||
if let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
} else {
|
||||
Text(actionTitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCard<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent: Bool = false
|
||||
var padding: CGFloat = 14
|
||||
var radius: CGFloat = OpenClawProMetric.cardRadius
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
self.content
|
||||
.padding(self.padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.proPanelSurface(
|
||||
tint: self.tint,
|
||||
radius: self.radius,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let radius: CGFloat
|
||||
let tint: Color?
|
||||
let isProminent: Bool
|
||||
|
||||
var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
ProPanelTexture()
|
||||
.opacity(self.colorScheme == .dark ? 0.22 : 0.08)
|
||||
.clipShape(shape)
|
||||
}
|
||||
.overlay {
|
||||
shape.strokeBorder(self.borderStyle, lineWidth: 1)
|
||||
}
|
||||
.overlay {
|
||||
shape
|
||||
.strokeBorder(Color.black.opacity(self.colorScheme == .dark ? 0.40 : 0.055), lineWidth: 0.7)
|
||||
.padding(1)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
shape
|
||||
.strokeBorder(Color.white.opacity(self.colorScheme == .dark ? 0.07 : 0.36), lineWidth: 0.7)
|
||||
.mask(alignment: .top) {
|
||||
Rectangle().frame(height: 28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var fill: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
let base = self.isProminent
|
||||
? Color(red: 15 / 255, green: 17 / 255, blue: 19 / 255)
|
||||
: Color(red: 10 / 255, green: 12 / 255, blue: 14 / 255)
|
||||
return AnyShapeStyle(base)
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.98),
|
||||
(self.tint ?? Color.white).opacity(self.tint == nil ? 0.92 : 0.12),
|
||||
Color(red: 246 / 255, green: 247 / 255, blue: 249 / 255),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
|
||||
private var borderStyle: AnyShapeStyle {
|
||||
if self.colorScheme == .dark {
|
||||
return AnyShapeStyle(Color.white.opacity(self.isProminent ? 0.15 : 0.11))
|
||||
}
|
||||
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.72),
|
||||
(self.tint ?? OpenClawBrand.accent).opacity(0.10),
|
||||
Color.black.opacity(0.08),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelTexture: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Canvas { context, size in
|
||||
let color = self.colorScheme == .dark ? Color.white.opacity(0.11) : Color.black.opacity(0.08)
|
||||
for y in stride(from: 2.0, through: size.height, by: 6.5) {
|
||||
let offset = Int(y / 6.5).isMultiple(of: 2) ? 0.0 : 3.25
|
||||
for x in stride(from: 2.0 + offset, through: size.width, by: 6.5) {
|
||||
let dot = CGRect(x: x, y: y, width: 0.7, height: 0.7)
|
||||
context.fill(Path(ellipseIn: dot), with: .color(color))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProLightGlassModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let radius: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *), self.colorScheme == .light {
|
||||
content.glassEffect(.regular, in: .rect(cornerRadius: self.radius))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProGlassSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let fill: Color
|
||||
let stroke: Color
|
||||
let radius: CGFloat
|
||||
let isProminent: Bool
|
||||
var interactive = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: self.radius, style: .continuous)
|
||||
let surfaced = content.background {
|
||||
shape
|
||||
.fill(self.fill)
|
||||
.overlay {
|
||||
shape.strokeBorder(self.stroke, lineWidth: self.isProminent ? 1.2 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 26.0, *), self.colorScheme == .light {
|
||||
surfaced.glassEffect(
|
||||
self.interactive ? .regular.interactive() : .regular,
|
||||
in: .rect(cornerRadius: self.radius))
|
||||
} else {
|
||||
surfaced
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func proPanelSurface(
|
||||
tint: Color? = nil,
|
||||
radius: CGFloat = OpenClawProMetric.cardRadius,
|
||||
isProminent: Bool = false) -> some View
|
||||
{
|
||||
self.modifier(ProPanelSurfaceModifier(
|
||||
tint: tint,
|
||||
radius: radius,
|
||||
isProminent: isProminent))
|
||||
}
|
||||
|
||||
func proGlassSurface(
|
||||
fill: Color,
|
||||
stroke: Color,
|
||||
radius: CGFloat,
|
||||
isProminent: Bool = false,
|
||||
interactive: Bool = false) -> some View
|
||||
{
|
||||
self.modifier(ProGlassSurfaceModifier(
|
||||
fill: fill,
|
||||
stroke: stroke,
|
||||
radius: radius,
|
||||
isProminent: isProminent,
|
||||
interactive: interactive))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProPanelSurfaceModifier: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let tint: Color?
|
||||
let radius: CGFloat
|
||||
let isProminent: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background {
|
||||
ProPanelBackground(
|
||||
radius: self.radius,
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent)
|
||||
}
|
||||
.modifier(ProLightGlassModifier(radius: self.radius))
|
||||
.shadow(
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
|
||||
radius: self.isProminent ? 20 : 12,
|
||||
y: self.isProminent ? 10 : 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProIconBadge: View {
|
||||
let systemName: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 34, height: 34)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusDot: View {
|
||||
var color: Color
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(self.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: self.color.opacity(0.35), radius: 4)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProValuePill: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(self.value)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.color.opacity(self.colorScheme == .dark ? 0.12 : 0.08))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawProMark: View {
|
||||
var size: CGFloat = 42
|
||||
var shadowRadius: CGFloat = 10
|
||||
|
||||
var body: some View {
|
||||
Image("OpenClawIcon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
}
|
||||
}
|
||||
|
||||
struct ProProgressBar: View {
|
||||
let progress: Double
|
||||
var color: Color = OpenClawBrand.accentHot
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let clamped = max(0, min(self.progress, 1))
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.primary.opacity(0.10))
|
||||
Capsule()
|
||||
.fill(self.color)
|
||||
.frame(width: proxy.size.width * clamped)
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProWorkRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let state: String
|
||||
let trailing: String
|
||||
let color: Color
|
||||
var progress: Double?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Text(self.trailing)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 8) {
|
||||
if let progress {
|
||||
ProProgressBar(progress: progress, color: self.color)
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
Text(self.state)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProCapsule: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let color: Color
|
||||
var icon: String?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
if let icon {
|
||||
Image(systemName: icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(self.color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.color.opacity(self.colorScheme == .dark ? 0.16 : 0.10))
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(self.color.opacity(self.colorScheme == .dark ? 0.30 : 0.18), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@Binding var selection: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
|
||||
Button {
|
||||
self.selection = index
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 9)
|
||||
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.trackFill)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func segmentFill(isSelected: Bool) -> Color {
|
||||
guard isSelected else { return .clear }
|
||||
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private var trackFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
|
||||
}
|
||||
|
||||
private var trackStroke: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProHeroActionButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.subheadline.weight(.bold))
|
||||
.foregroundStyle(OpenClawBrand.accentHot)
|
||||
}
|
||||
.padding(12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
|
||||
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
|
||||
radius: 18,
|
||||
isProminent: true,
|
||||
interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetricTile: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let title: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Image(systemName: self.icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10), in: Circle())
|
||||
Spacer(minLength: 4)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.value)
|
||||
.font(.headline.weight(.bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Text(self.title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.04) : Color.white.opacity(0.52),
|
||||
stroke: self.color.opacity(self.colorScheme == .dark ? 0.18 : 0.10),
|
||||
radius: 16)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.value, color: self.color)
|
||||
}
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProTimelineRow: View {
|
||||
let done: Bool
|
||||
let title: String
|
||||
let detail: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ProIconBadge(
|
||||
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
|
||||
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
167
apps/ios/Sources/Design/SettingsProTab.swift
Normal file
167
apps/ios/Sources/Design/SettingsProTab.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
struct SettingsProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(VoiceWakeManager.self) var voiceWake
|
||||
@Environment(GatewayConnectionController.self) var gatewayController
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@AppStorage(AppAppearancePreference.storageKey) var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@AppStorage("node.displayName") var displayName: String = "iOS Node"
|
||||
@AppStorage("node.instanceId") var instanceId: String = UUID().uuidString
|
||||
@AppStorage("camera.enabled") var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") var locationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("screen.preventSleep") var preventSleep: Bool = true
|
||||
@AppStorage("talk.enabled") var talkEnabled: Bool = false
|
||||
@AppStorage(TalkModeProviderSelection.storageKey) var talkProviderSelectionRaw: String =
|
||||
TalkModeProviderSelection.gatewayDefault.rawValue
|
||||
@AppStorage(TalkModeRealtimeVoiceSelection.storageKey) var talkRealtimeVoiceSelectionRaw: String = ""
|
||||
@AppStorage(TalkSpeechLocale.storageKey) var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage("talk.button.enabled") var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage(TalkDefaults.speakerphoneEnabledKey) var talkSpeakerphoneEnabled: Bool =
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("gateway.autoconnect") var gatewayAutoConnect: Bool = false
|
||||
@AppStorage("gateway.manual.enabled") var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("gateway.setupCode") var setupCode: String = ""
|
||||
@AppStorage("gateway.onboardingComplete") var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") var hasConnectedOnce: Bool = false
|
||||
@AppStorage("onboarding.requestID") var onboardingRequestID: Int = 0
|
||||
@State var isReconnectingGateway = false
|
||||
@State var isRefreshingGateway = false
|
||||
@State var isChangingLocationMode = false
|
||||
@State var connectingGatewayID: String?
|
||||
@State var selectedAgentPickerId = ""
|
||||
@State var gatewayToken = ""
|
||||
@State var gatewayPassword = ""
|
||||
@State var manualGatewayPortText = ""
|
||||
@State var setupStatusText: String?
|
||||
@State var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
|
||||
@State var defaultShareInstruction = ""
|
||||
@State var showGatewayProblemDetails = false
|
||||
@State var showQRScanner = false
|
||||
@State var scannerError: String?
|
||||
@State var showResetOnboardingAlert = false
|
||||
@State var suppressCredentialPersist = false
|
||||
@State var locationStatusText: String?
|
||||
@State var previousLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State var notificationStatusText = "Checking"
|
||||
@State var notificationActionText = "Request Access"
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.settingsHeader
|
||||
self.appearanceSection
|
||||
self.gatewaySection
|
||||
self.settingsListSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
.task {
|
||||
self.previousLocationModeRaw = self.locationModeRaw
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
}
|
||||
}
|
||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||
self.handleLocationModeChange(newValue)
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.persistGatewayToken(newValue)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.persistGatewayPassword(newValue)
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.setupStatusText = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("This disconnects, clears saved gateway credentials, and reopens onboarding.")
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
646
apps/ios/Sources/Design/SettingsProTabActions.swift
Normal file
646
apps/ios/Sources/Design/SettingsProTabActions.swift
Normal file
@@ -0,0 +1,646 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
extension SettingsProTab {
|
||||
func detailStatusCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var diagnosticChecksCard: some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.diagnosticCheckRow(
|
||||
icon: "stethoscope",
|
||||
title: "Last Run",
|
||||
detail: self.diagnosticsLastRunText,
|
||||
value: self.diagnosticsRunValue,
|
||||
color: self.diagnosticsRunColor)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway Link",
|
||||
detail: self.appModel.gatewayDisplayStatusText,
|
||||
value: self.gatewayConnected ? "online" : "offline",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "dot.radiowaves.left.and.right",
|
||||
title: "Discovery",
|
||||
detail: self.gatewayController.discoveryStatusText,
|
||||
value: "\(self.gatewayController.gateways.count)",
|
||||
color: self.gatewayController.gateways.isEmpty ? .secondary : OpenClawBrand.accent)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "waveform",
|
||||
title: "Talk Config",
|
||||
detail: self.appModel.talkMode.gatewayTalkTransportLabel,
|
||||
value: self.appModel.talkMode.gatewayTalkConfigLoaded ? "loaded" : "missing",
|
||||
color: self.appModel.talkMode.gatewayTalkConfigLoaded ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: "Approval and event alert channel",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "rectangle.on.rectangle",
|
||||
title: "Screen Capture",
|
||||
detail: "Live foreground capture state",
|
||||
value: self.appModel.screenRecordActive ? "live" : "idle",
|
||||
color: self.appModel.screenRecordActive ? OpenClawBrand.ok : .secondary)
|
||||
Divider().padding(.leading, 60)
|
||||
self.diagnosticCheckRow(
|
||||
icon: "mic",
|
||||
title: "Voice Wake",
|
||||
detail: self.appModel.voiceWake.statusText,
|
||||
value: self.voiceWakeEnabled ? "on" : "off",
|
||||
color: self.voiceWakeEnabled ? OpenClawBrand.ok : .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func diagnosticCheckRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: color)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: value, color: color)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
func detailListCard(@ViewBuilder content: () -> some View) -> some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0, content: content)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func detailRow(_ label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 42)
|
||||
}
|
||||
|
||||
func reconnectGateway() async {
|
||||
guard !self.isReconnectingGateway else { return }
|
||||
self.isReconnectingGateway = true
|
||||
defer { self.isReconnectingGateway = false }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
func refreshGateway() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func runDiagnostics() async {
|
||||
guard !self.isRefreshingGateway else { return }
|
||||
self.isRefreshingGateway = true
|
||||
defer { self.isRefreshingGateway = false }
|
||||
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
self.gatewayController.restartDiscovery()
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count,
|
||||
talkConfigLoaded: self.appModel.talkMode.gatewayTalkConfigLoaded,
|
||||
notificationStatusText: self.notificationStatusText)
|
||||
self.diagnosticsIssueCount = issueCount
|
||||
self.diagnosticsLastRunText = SettingsDiagnostics.timestamp(Date())
|
||||
}
|
||||
|
||||
func syncSettingsState() {
|
||||
self.manualGatewayPortText = self.manualGatewayPort > 0 ? String(self.manualGatewayPort) : ""
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
defer { self.connectingGatewayID = nil }
|
||||
self.manualGatewayEnabled = false
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(gateway.stableID)
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
if let err = await self.gatewayController.connectWithDiagnostics(gateway) {
|
||||
self.setupStatusText = err
|
||||
}
|
||||
}
|
||||
|
||||
func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let port = self.resolvedManualPort(host: host) else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting..."
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func applySetupCode() -> Bool {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return false
|
||||
}
|
||||
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
|
||||
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
|
||||
return false
|
||||
}
|
||||
self.applyGatewayLink(link)
|
||||
return true
|
||||
}
|
||||
|
||||
func applyGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualGatewayHost = link.host
|
||||
self.manualGatewayPort = link.port
|
||||
self.manualGatewayPortText = String(link.port)
|
||||
self.manualGatewayTLS = link.tls
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
|
||||
if setupAuth.hasBootstrapToken {
|
||||
GatewayOnboardingReset.prepareForBootstrapPairing(appModel: self.appModel, instanceId: instanceId)
|
||||
}
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: instanceId)
|
||||
}
|
||||
if setupAuth.shouldApplyTokenField {
|
||||
self.gatewayToken = setupAuth.token
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(setupAuth.token, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
if setupAuth.shouldApplyPasswordField {
|
||||
self.gatewayPassword = setupAuth.password
|
||||
if !instanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayPassword(setupAuth.password, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
|
||||
}
|
||||
|
||||
func openGatewayQRScanner() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = "Opening QR scanner..."
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
func handleScannedGatewayLink(_ link: GatewayConnectDeepLink) {
|
||||
self.showQRScanner = false
|
||||
self.setupCode = ""
|
||||
self.applyGatewayLink(link)
|
||||
self.setupStatusText = "QR loaded. Connecting to \(link.host):\(link.port)..."
|
||||
Task { await self.connectAfterScannedGatewayLink() }
|
||||
}
|
||||
|
||||
func connectAfterScannedGatewayLink() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let port = self.resolvedManualPort(host: host) else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.setupStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualPortIsValid else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
self.connectingGatewayID = "manual"
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
|
||||
token: self.gatewayToken,
|
||||
pendingOverride: self.pendingManualAuthOverride,
|
||||
password: self.gatewayPassword)
|
||||
self.pendingManualAuthOverride = nil
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS,
|
||||
authOverride: authOverride)
|
||||
}
|
||||
|
||||
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {
|
||||
self.setupStatusText = "Tailscale is off on this iPhone. Turn it on, then try again."
|
||||
return false
|
||||
}
|
||||
self.setupStatusText = "Checking gateway reachability..."
|
||||
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
|
||||
if !ok {
|
||||
self.setupStatusText = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func resetOnboarding() {
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.setupCode = ""
|
||||
self.gatewayAutoConnect = false
|
||||
self.suppressCredentialPersist = true
|
||||
defer { self.suppressCredentialPersist = false }
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.onboardingComplete = false
|
||||
self.hasConnectedOnce = false
|
||||
self.manualGatewayEnabled = false
|
||||
self.manualGatewayHost = ""
|
||||
self.onboardingRequestID += 1
|
||||
}
|
||||
|
||||
func retryGatewayConnectionFromProblem() async {
|
||||
if self.manualGatewayEnabled || self.connectingGatewayID == "manual" {
|
||||
await self.connectManual()
|
||||
} else {
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
if problem.suggestsOnboardingReset { return "Reset onboarding" }
|
||||
return problem.canTrustRotatedCertificate ? "Trust certificate" : "Retry connection"
|
||||
}
|
||||
|
||||
func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) async {
|
||||
if problem.suggestsOnboardingReset {
|
||||
self.resetOnboarding()
|
||||
return
|
||||
}
|
||||
if problem.canTrustRotatedCertificate {
|
||||
_ = await self.gatewayController.trustRotatedGatewayCertificate(from: problem)
|
||||
return
|
||||
}
|
||||
await self.retryGatewayConnectionFromProblem()
|
||||
}
|
||||
|
||||
func handleLocationModeChange(_ newValue: String) {
|
||||
guard !self.isChangingLocationMode else { return }
|
||||
guard newValue != self.previousLocationModeRaw else { return }
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
let previous = self.previousLocationModeRaw
|
||||
Task {
|
||||
await self.applyLocationMode(mode, rawValue: newValue, previous: previous)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyLocationMode(
|
||||
_ mode: OpenClawLocationMode,
|
||||
rawValue: String,
|
||||
previous: String) async
|
||||
{
|
||||
self.isChangingLocationMode = true
|
||||
self.locationStatusText = nil
|
||||
defer { self.isChangingLocationMode = false }
|
||||
|
||||
if mode == .off {
|
||||
self.previousLocationModeRaw = rawValue
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
return
|
||||
}
|
||||
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if granted {
|
||||
self.previousLocationModeRaw = rawValue
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
} else {
|
||||
self.locationModeRaw = previous
|
||||
self.previousLocationModeRaw = previous
|
||||
self.locationStatusText = "Location permission was not granted."
|
||||
}
|
||||
}
|
||||
|
||||
func refreshNotificationSettings() {
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
let status = settings.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.applyNotificationStatus(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleNotificationAction() {
|
||||
if self.notificationStatusText == "Allowed" || self.notificationStatusText == "Not Allowed" {
|
||||
self.openSystemSettings()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
.alert,
|
||||
.badge,
|
||||
.sound,
|
||||
])) ?? false
|
||||
await MainActor.run {
|
||||
self.notificationStatusText = granted ? "Allowed" : "Not Allowed"
|
||||
self.notificationActionText = granted ? "Open System Settings" : "Open System Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
switch status {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
self.notificationStatusText = "Allowed"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
case .denied:
|
||||
self.notificationStatusText = "Not Allowed"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
case .notDetermined:
|
||||
self.notificationStatusText = "Not Set"
|
||||
self.notificationActionText = "Request Access"
|
||||
@unknown default:
|
||||
self.notificationStatusText = "Unknown"
|
||||
self.notificationActionText = "Open System Settings"
|
||||
}
|
||||
}
|
||||
|
||||
func persistGatewayToken(_ value: String) {
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func persistGatewayPassword(_ value: String) {
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
instanceId: instanceId)
|
||||
}
|
||||
|
||||
func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
func title(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Gateway"
|
||||
case .permissions: "Permissions"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
case .privacy: "Privacy"
|
||||
case .notifications: "Notifications"
|
||||
case .about: "About"
|
||||
}
|
||||
}
|
||||
|
||||
var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
self.manualGatewayPortText = filtered
|
||||
self.manualGatewayPort = Int(filtered) ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
func resolvedManualPort(host: String) -> Int? {
|
||||
if self.manualGatewayPort > 0 {
|
||||
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
||||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if self.manualGatewayTLS, trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}
|
||||
|
||||
var setupStatusLine: String? {
|
||||
if let problem = self.appModel.lastGatewayProblem {
|
||||
return problem.message
|
||||
}
|
||||
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
||||
if !trimmedSetup.isEmpty { return trimmedSetup }
|
||||
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
||||
return gatewayStatus
|
||||
}
|
||||
|
||||
var tailnetWarningText: String? {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, Self.isTailnetHostOrIP(host), !Self.hasTailnetIPv4() else { return nil }
|
||||
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
|
||||
}
|
||||
|
||||
func friendlyGatewayMessage(from raw: String) -> String? {
|
||||
let lower = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if lower.contains("pairing required") {
|
||||
return "Pairing required. Run /pair approve in your OpenClaw chat, then connect again."
|
||||
}
|
||||
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
||||
return "Secure handshake failed. Check Tailscale, then connect again."
|
||||
}
|
||||
if lower.contains("timed out") {
|
||||
return "Connection timed out. Make sure Tailscale is connected, then try again."
|
||||
}
|
||||
if lower.contains("unauthorized role") {
|
||||
return "Connected, but some controls are restricted for nodes. This is expected."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var shouldShowRealtimeVoicePicker: Bool {
|
||||
let providerSelection = TalkModeProviderSelection.resolved(self.talkProviderSelectionRaw)
|
||||
return providerSelection == .openAIRealtime || self.appModel.talkMode.gatewayTalkUsesRealtime
|
||||
}
|
||||
|
||||
var talkProviderSelectionBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.talkProviderSelectionRaw },
|
||||
set: { newValue in
|
||||
let selection = TalkModeProviderSelection.resolved(newValue)
|
||||
self.talkProviderSelectionRaw = selection.rawValue
|
||||
self.appModel.setTalkProviderSelection(selection.rawValue)
|
||||
})
|
||||
}
|
||||
|
||||
var talkRealtimeVoiceSelectionBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.talkRealtimeVoiceSelectionRaw },
|
||||
set: { newValue in
|
||||
let voice = TalkModeRealtimeVoiceSelection.resolvedOverride(newValue) ?? ""
|
||||
self.talkRealtimeVoiceSelectionRaw = voice
|
||||
self.appModel.setTalkRealtimeVoiceSelection(voice)
|
||||
})
|
||||
}
|
||||
|
||||
var talkSpeakerphoneBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { self.talkSpeakerphoneEnabled },
|
||||
set: { newValue in
|
||||
self.talkSpeakerphoneEnabled = newValue
|
||||
self.appModel.setTalkSpeakerphoneEnabled(newValue)
|
||||
})
|
||||
}
|
||||
|
||||
var talkApiKeyStatus: String {
|
||||
guard self.appModel.talkMode.gatewayTalkConfigLoaded else { return "Not loaded" }
|
||||
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
if let tailnet = gateway.tailnetDns { lines.append("Tailnet: \(tailnet)") }
|
||||
let gw = gateway.gatewayPort.map(String.init)
|
||||
let canvas = gateway.canvasPort.map(String.init)
|
||||
if gw != nil || canvas != nil {
|
||||
lines.append("Ports: gateway \(gw ?? "-") / canvas \(canvas ?? "-")")
|
||||
}
|
||||
return lines.isEmpty ? [gateway.debugID] : lines
|
||||
}
|
||||
|
||||
var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
var gatewayAddress: String {
|
||||
self.appModel.gatewayRemoteAddress ?? "Waiting for gateway"
|
||||
}
|
||||
|
||||
var gatewayServer: String {
|
||||
self.appModel.gatewayServerName ?? "OpenClaw Gateway"
|
||||
}
|
||||
|
||||
var permissionsDetail: String {
|
||||
var enabled = 0
|
||||
if self.cameraEnabled { enabled += 1 }
|
||||
if self.locationModeRaw != OpenClawLocationMode.off.rawValue { enabled += 1 }
|
||||
if self.preventSleep { enabled += 1 }
|
||||
return "\(enabled) enabled"
|
||||
}
|
||||
|
||||
var voiceDetail: String {
|
||||
if self.talkEnabled, self.voiceWakeEnabled { return "Talk + Wake" }
|
||||
if self.talkEnabled { return "Talk on" }
|
||||
if self.voiceWakeEnabled { return "Wake on" }
|
||||
return "Off"
|
||||
}
|
||||
|
||||
var diagnosticsDetail: String {
|
||||
"System checks"
|
||||
}
|
||||
|
||||
var diagnosticsHealthValue: String {
|
||||
if self.gatewayConnected { return "ready" }
|
||||
if self.gatewayController.gateways.isEmpty { return "check" }
|
||||
return "partial"
|
||||
}
|
||||
|
||||
var diagnosticsRunValue: String {
|
||||
guard let diagnosticsIssueCount else { return "pending" }
|
||||
return diagnosticsIssueCount == 0 ? "pass" : "\(diagnosticsIssueCount)"
|
||||
}
|
||||
|
||||
var diagnosticsRunColor: Color {
|
||||
guard let diagnosticsIssueCount else { return .secondary }
|
||||
return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn
|
||||
}
|
||||
|
||||
var privacyDetail: String {
|
||||
let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||
return location == .off ? "Location off" : "Location \(self.locationLabel)"
|
||||
}
|
||||
|
||||
var locationLabel: String {
|
||||
switch OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off {
|
||||
case .off: "Off"
|
||||
case .whileUsing: "While Using"
|
||||
case .always: "Always"
|
||||
}
|
||||
}
|
||||
}
|
||||
807
apps/ios/Sources/Design/SettingsProTabSections.swift
Normal file
807
apps/ios/Sources/Design/SettingsProTabSections.swift
Normal file
@@ -0,0 +1,807 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
extension SettingsProTab {
|
||||
var settingsHeader: some View {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var appearanceSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Appearance", uppercase: false)
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Appearance", selection: self.$appearancePreferenceRaw) {
|
||||
ForEach(AppAppearancePreference.allCases) { preference in
|
||||
Text(preference.label).tag(preference.rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
Text("Follows iOS appearance.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var gatewaySection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Gateway", uppercase: false)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(value: SettingsRoute.gateway) {
|
||||
self.gatewayConnectionRow
|
||||
.padding(14)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Address", value: self.gatewayAddress)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Server", value: self.gatewayServer)
|
||||
Divider()
|
||||
self.gatewayDetailRow(label: "Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
Divider()
|
||||
self.gatewayActions
|
||||
.padding(14)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var gatewayConnectionRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "antenna.radiowaves.left.and.right",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Connection")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayDetailRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 40)
|
||||
}
|
||||
|
||||
var gatewayActions: some View {
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayActionButton(
|
||||
title: "Reconnect",
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
color: OpenClawBrand.warn,
|
||||
isBusy: self.isReconnectingGateway)
|
||||
{
|
||||
Task { await self.reconnectGateway() }
|
||||
}
|
||||
|
||||
self.gatewayActionButton(
|
||||
title: "Diagnose",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var settingsListSection: some View {
|
||||
VStack(spacing: 10) {
|
||||
self.settingsListRow(
|
||||
icon: "person.2",
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.voiceDetail,
|
||||
route: .voice)
|
||||
self.settingsListRow(
|
||||
icon: "globe",
|
||||
title: "Diagnostics",
|
||||
detail: self.diagnosticsDetail,
|
||||
route: .diagnostics)
|
||||
self.settingsListRow(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: self.privacyDetail,
|
||||
route: .privacy)
|
||||
self.settingsListRow(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: self.notificationStatusText,
|
||||
route: .notifications)
|
||||
self.settingsListRow(
|
||||
icon: "info.circle",
|
||||
title: "About",
|
||||
detail: DeviceInfoHelper.openClawVersionString(),
|
||||
route: .about)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func settingsListRow(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
route: SettingsRoute) -> some View
|
||||
{
|
||||
NavigationLink(value: route) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, minHeight: SettingsLayout.rowHeight, alignment: .leading)
|
||||
.proPanelSurface(radius: SettingsLayout.cardRadius)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
func destination(for route: SettingsRoute) -> some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .voice:
|
||||
self.voiceDestination
|
||||
case .diagnostics:
|
||||
self.diagnosticsDestination
|
||||
case .privacy:
|
||||
self.privacyDestination
|
||||
case .notifications:
|
||||
self.notificationsDestination
|
||||
case .about:
|
||||
self.aboutDestination
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var gatewayDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
self.gatewayProblemCard(gatewayProblem)
|
||||
}
|
||||
|
||||
self.detailStatusCard(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayConnected ? "Connected" : self.appModel.gatewayDisplayStatusText,
|
||||
value: self.gatewayConnected ? "online" : "offline",
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary)
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Address", value: self.gatewayAddress)
|
||||
Divider()
|
||||
self.detailRow("Server", value: self.gatewayServer)
|
||||
Divider()
|
||||
self.detailRow("Discovered", value: "\(self.gatewayController.gateways.count)")
|
||||
Divider()
|
||||
self.detailRow("Active Agent", value: self.appModel.activeAgentName)
|
||||
Divider()
|
||||
self.detailRow("Agents", value: "\(self.appModel.gatewayAgents.count)")
|
||||
}
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
self.gatewayActions
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.deviceIdentityCard
|
||||
self.agentSelectionCard
|
||||
self.gatewaySetupCard
|
||||
self.discoveredGatewaysCard
|
||||
self.manualGatewayCard
|
||||
self.gatewayAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
var permissionsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.toggleCard(
|
||||
icon: "camera",
|
||||
title: "Camera",
|
||||
detail: "Allow the gateway to request photos or video while OpenClaw is foregrounded.",
|
||||
isOn: self.$cameraEnabled)
|
||||
|
||||
self.locationModeCard
|
||||
|
||||
self.toggleCard(
|
||||
icon: "lock.display",
|
||||
title: "Keep Awake",
|
||||
detail: "Keep the screen awake while OpenClaw is open.",
|
||||
isOn: self.$preventSleep)
|
||||
|
||||
self.privacyAccessCard
|
||||
}
|
||||
}
|
||||
|
||||
var voiceDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
detail: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
|
||||
value: self.voiceDetail,
|
||||
color: self.talkEnabled || self.voiceWakeEnabled ? OpenClawBrand.accent : .secondary)
|
||||
|
||||
self.voiceFeatureCard
|
||||
self.talkVoiceSettingsCard
|
||||
self.shareSettingsCard
|
||||
}
|
||||
}
|
||||
|
||||
var diagnosticsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "checklist.checked",
|
||||
title: "Health Check",
|
||||
detail: "Run app, permission, and gateway-adjacent checks without editing setup.",
|
||||
value: self.diagnosticsHealthValue,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : OpenClawBrand.warn)
|
||||
|
||||
self.diagnosticChecksCard
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("App", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
self.gatewayActionButton(
|
||||
title: "Run Diagnostics",
|
||||
icon: "cross.case",
|
||||
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
|
||||
isBusy: self.isRefreshingGateway)
|
||||
{
|
||||
Task { await self.runDiagnostics() }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
self.diagnosticsAdvancedCard
|
||||
}
|
||||
}
|
||||
|
||||
var privacyDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "hand.raised",
|
||||
title: "Privacy",
|
||||
detail: "Control what device context OpenClaw can expose to the gateway.",
|
||||
value: self.privacyDetail,
|
||||
color: .secondary)
|
||||
|
||||
self.toggleCard(
|
||||
icon: "camera",
|
||||
title: "Camera Access",
|
||||
detail: "Disable to block camera capture requests from the gateway.",
|
||||
isOn: self.$cameraEnabled)
|
||||
|
||||
self.locationModeCard
|
||||
|
||||
self.toggleCard(
|
||||
icon: "lock.open.display",
|
||||
title: "Background Listening",
|
||||
detail: "Allow active Talk sessions to continue while the app is backgrounded.",
|
||||
isOn: self.$talkBackgroundEnabled)
|
||||
|
||||
self.privacyAccessCard
|
||||
}
|
||||
}
|
||||
|
||||
var notificationsDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "bell",
|
||||
title: "Notifications",
|
||||
detail: "Approvals and event alerts from OpenClaw.",
|
||||
value: self.notificationStatusText,
|
||||
color: self.notificationStatusText == "Allowed" ? OpenClawBrand.ok : .secondary)
|
||||
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
self.handleNotificationAction()
|
||||
} label: {
|
||||
Label(
|
||||
self.notificationActionText,
|
||||
systemImage: self.notificationStatusText == "Allowed" ? "gear" : "bell.badge")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Text("OpenClaw uses notifications for approval prompts and mirrored event alerts.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
var aboutDestination: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.detailStatusCard(
|
||||
icon: "info.circle",
|
||||
title: "OpenClaw",
|
||||
detail: "iOS companion app",
|
||||
value: DeviceInfoHelper.openClawVersionString(),
|
||||
color: OpenClawBrand.accent)
|
||||
|
||||
self.detailListCard {
|
||||
self.detailRow("Version", value: DeviceInfoHelper.openClawVersionString())
|
||||
Divider()
|
||||
self.detailRow("Device", value: DeviceInfoHelper.deviceFamily())
|
||||
Divider()
|
||||
self.detailRow("Platform", value: DeviceInfoHelper.platformStringForDisplay())
|
||||
Divider()
|
||||
self.detailRow("Model", value: DeviceInfoHelper.modelIdentifier())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayActionButton(
|
||||
title: String,
|
||||
icon: String,
|
||||
color: Color,
|
||||
isBusy: Bool,
|
||||
action: @escaping () -> Void) -> some View
|
||||
{
|
||||
Button(action: action) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: isBusy ? "hourglass" : icon)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.76)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
.foregroundStyle(color)
|
||||
.background(color.opacity(0.09), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.strokeBorder(color.opacity(0.14))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
|
||||
func toggleCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
detail: String,
|
||||
isOn: Binding<Bool>) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
Toggle(isOn: isOn) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: isOn.wrappedValue ? OpenClawBrand.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var locationModeCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(
|
||||
systemName: "location",
|
||||
color: self.locationModeRaw == OpenClawLocationMode.off.rawValue ? .secondary : OpenClawBrand
|
||||
.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Location")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Controls whether location can be shared with gateway tools.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if self.isChangingLocationMode {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Location", selection: self.$locationModeRaw) {
|
||||
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
||||
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.disabled(self.isChangingLocationMode)
|
||||
|
||||
if let locationStatusText {
|
||||
Text(locationStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var agentSelectionCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Active Agent")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Agent", selection: self.$selectedAgentPickerId) {
|
||||
Text("Default").tag("")
|
||||
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
|
||||
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Text(name.isEmpty ? agent.id : name).tag(agent.id)
|
||||
}
|
||||
}
|
||||
Text("Controls which agent Chat and Talk use.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var gatewaySetupCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Setup Code")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayActionButton(
|
||||
title: "Scan QR",
|
||||
icon: "qrcode.viewfinder",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.connectingGatewayID != nil)
|
||||
{
|
||||
self.openGatewayQRScanner()
|
||||
}
|
||||
self.gatewayActionButton(
|
||||
title: "Connect",
|
||||
icon: "bolt.horizontal.circle",
|
||||
color: OpenClawBrand.ok,
|
||||
isBusy: self.connectingGatewayID == "manual")
|
||||
{
|
||||
Task { await self.applySetupCodeAndConnect() }
|
||||
}
|
||||
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
if let status = self.setupStatusLine {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
if let warning = self.tailnetWarningText {
|
||||
Text(warning)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var discoveredGatewaysCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Discovered Gateways")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet. Use manual setup if Bonjour is blocked.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.gatewayController.gateways) { gateway in
|
||||
self.discoveredGatewayRow(gateway)
|
||||
if gateway.id != self.gatewayController.gateways.last?.id {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(verbatim: gateway.name)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
Task { await self.connect(gateway) }
|
||||
} label: {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
|
||||
var manualGatewayCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Port", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
self.gatewayActionButton(
|
||||
title: "Connect Manual",
|
||||
icon: "network",
|
||||
color: OpenClawBrand.accent,
|
||||
isBusy: self.connectingGatewayID == "manual")
|
||||
{
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
.disabled(self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !self.manualPortIsValid)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var gatewayAdvancedCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
SecureField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Reset Onboarding", role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var voiceFeatureCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.settingsToggle("Voice Wake", isOn: self.$voiceWakeEnabled) { enabled in
|
||||
self.appModel.setVoiceWakeEnabled(enabled)
|
||||
}
|
||||
self.settingsToggle("Talk Mode", isOn: self.$talkEnabled) { enabled in
|
||||
self.appModel.setTalkEnabled(enabled)
|
||||
}
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).tag(option.id)
|
||||
}
|
||||
}
|
||||
self.settingsToggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||||
self.settingsToggle("Speakerphone", isOn: self.talkSpeakerphoneBinding)
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
} label: {
|
||||
self.simpleSettingsRow(
|
||||
title: "Wake Words",
|
||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var talkVoiceSettingsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
}
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider()
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
Divider()
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var shareSettingsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Show Talk Control", isOn: self.$talkButtonEnabled)
|
||||
TextField("Default Share Instruction", text: self.$defaultShareInstruction, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
.textInputAutocapitalization(.sentences)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button {
|
||||
Task { await self.appModel.runSharePipelineSelfTest() }
|
||||
} label: {
|
||||
Label("Run Share Self-Test", systemImage: "checkmark.seal")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Text(self.appModel.lastShareEventText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var privacyAccessCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
PrivacyAccessSectionView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var diagnosticsAdvancedCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, enabled in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(enabled)
|
||||
}
|
||||
Toggle("Debug Screen Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
NavigationLink {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
} label: {
|
||||
self.simpleSettingsRow(title: "Discovery Logs", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
var deviceIdentityCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("Device Name", text: self.$displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
self.detailRow("Instance ID", value: self.instanceId)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func gatewayProblemCard(_ problem: GatewayConnectionProblem) -> some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
GatewayProblemBanner(
|
||||
problem: problem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(problem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(problem) }
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
func settingsToggle(
|
||||
_ title: String,
|
||||
isOn: Binding<Bool>,
|
||||
onChange: ((Bool) -> Void)? = nil) -> some View
|
||||
{
|
||||
Toggle(title, isOn: isOn)
|
||||
.onChange(of: isOn.wrappedValue) { _, enabled in
|
||||
onChange?(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func simpleSettingsRow(title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
}
|
||||
105
apps/ios/Sources/Design/SettingsProTabSupport.swift
Normal file
105
apps/ios/Sources/Design/SettingsProTabSupport.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Darwin
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case permissions
|
||||
case voice
|
||||
case diagnostics
|
||||
case privacy
|
||||
case notifications
|
||||
case about
|
||||
}
|
||||
|
||||
enum SettingsLayout {
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let rowHeight: CGFloat = 58
|
||||
}
|
||||
|
||||
enum SettingsDiagnosticIssue: String, Equatable, CaseIterable {
|
||||
case gatewayOffline
|
||||
case discoveryUnavailable
|
||||
case talkConfigMissing
|
||||
case notificationsUnavailable
|
||||
}
|
||||
|
||||
enum SettingsDiagnostics {
|
||||
static func issues(
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationStatusText: String) -> [SettingsDiagnosticIssue]
|
||||
{
|
||||
var issues: [SettingsDiagnosticIssue] = []
|
||||
if !gatewayConnected { issues.append(.gatewayOffline) }
|
||||
if discoveredGatewayCount == 0 { issues.append(.discoveryUnavailable) }
|
||||
if gatewayConnected, !talkConfigLoaded { issues.append(.talkConfigMissing) }
|
||||
if notificationStatusText != "Allowed" { issues.append(.notificationsUnavailable) }
|
||||
return issues
|
||||
}
|
||||
|
||||
static func issueCount(
|
||||
gatewayConnected: Bool,
|
||||
discoveredGatewayCount: Int,
|
||||
talkConfigLoaded: Bool,
|
||||
notificationStatusText: String) -> Int
|
||||
{
|
||||
self.issues(
|
||||
gatewayConnected: gatewayConnected,
|
||||
discoveredGatewayCount: discoveredGatewayCount,
|
||||
talkConfigLoaded: talkConfigLoaded,
|
||||
notificationStatusText: notificationStatusText).count
|
||||
}
|
||||
|
||||
static func timestamp(_ date: Date) -> String {
|
||||
date.formatted(date: .omitted, time: .shortened)
|
||||
}
|
||||
}
|
||||
|
||||
extension SettingsProTab {
|
||||
static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
defer { freeifaddrs(addrList) }
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
guard let addrPtr = ptr.pointee.ifa_addr else { continue }
|
||||
let family = addrPtr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
var addr = addrPtr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(addrPtr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if self.isTailnetIPv4(ip) { return true }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func isTailnetHostOrIP(_ host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") { return true }
|
||||
return self.isTailnetIPv4(trimmed)
|
||||
}
|
||||
|
||||
static func isTailnetIPv4(_ ip: String) -> Bool {
|
||||
let parts = ip.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
guard (0...255).contains(a), (0...255).contains(b) else { return false }
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
@@ -16,7 +15,6 @@ private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
prompt: prompt,
|
||||
isResolving: self.appModel.pendingExecApprovalPromptResolving,
|
||||
errorText: self.appModel.pendingExecApprovalPromptErrorText,
|
||||
brighten: self.colorScheme == .light,
|
||||
onAllowOnce: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
|
||||
@@ -50,7 +48,6 @@ private struct ExecApprovalPromptCard: View {
|
||||
let prompt: NodeAppModel.ExecApprovalPrompt
|
||||
let isResolving: Bool
|
||||
let errorText: String?
|
||||
let brighten: Bool
|
||||
let onAllowOnce: () -> Void
|
||||
let onAllowAlways: () -> Void
|
||||
let onDeny: () -> Void
|
||||
@@ -147,7 +144,8 @@ private struct ExecApprovalPromptCard: View {
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
|
||||
.padding(18)
|
||||
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
|
||||
@@ -25,4 +25,53 @@ struct GatewayConnectConfig {
|
||||
if trimmed.isEmpty { return self.url.absoluteString }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func hasSameConnectionInputs(as other: GatewayConnectConfig) -> Bool {
|
||||
self.url == other.url &&
|
||||
self.stableID == other.stableID &&
|
||||
Self.sameTLS(self.tls, other.tls) &&
|
||||
self.token == other.token &&
|
||||
self.bootstrapToken == other.bootstrapToken &&
|
||||
self.password == other.password &&
|
||||
Self.sameOptions(self.nodeOptions, other.nodeOptions)
|
||||
}
|
||||
|
||||
private static func sameTLS(_ lhs: GatewayTLSParams?, _ rhs: GatewayTLSParams?) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (nil, nil):
|
||||
true
|
||||
case let (lhs?, rhs?):
|
||||
lhs.required == rhs.required &&
|
||||
lhs.expectedFingerprint == rhs.expectedFingerprint &&
|
||||
lhs.allowTOFU == rhs.allowTOFU &&
|
||||
lhs.storeKey == rhs.storeKey
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func sameOptions(_ lhs: GatewayConnectOptions, _ rhs: GatewayConnectOptions) -> Bool {
|
||||
let lhsScopes = Self.normalizedValues(lhs.scopes)
|
||||
let rhsScopes = Self.normalizedValues(rhs.scopes)
|
||||
let lhsCaps = Self.normalizedValues(lhs.caps)
|
||||
let rhsCaps = Self.normalizedValues(rhs.caps)
|
||||
let lhsCommands = Self.normalizedValues(lhs.commands)
|
||||
let rhsCommands = Self.normalizedValues(rhs.commands)
|
||||
return lhs.role == rhs.role &&
|
||||
lhs.scopesAreExplicit == rhs.scopesAreExplicit &&
|
||||
lhs.clientId == rhs.clientId &&
|
||||
lhs.clientMode == rhs.clientMode &&
|
||||
lhs.clientDisplayName == rhs.clientDisplayName &&
|
||||
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
|
||||
lhsScopes == rhsScopes &&
|
||||
lhsCaps == rhsCaps &&
|
||||
lhsCommands == rhsCommands &&
|
||||
lhs.permissions == rhs.permissions
|
||||
}
|
||||
|
||||
private static func normalizedValues(_ values: [String]) -> [String] {
|
||||
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,12 @@ final class GatewayConnectionController {
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var pendingTrustConnect: PendingTrustConnect?
|
||||
|
||||
private struct SavedManualEndpoint: Equatable {
|
||||
let host: String
|
||||
let port: Int
|
||||
let useTLS: Bool
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.appModel = appModel
|
||||
|
||||
@@ -181,7 +187,8 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
forceReconnect: Bool = false) async -> String?
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -245,7 +252,8 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
password: password,
|
||||
forceReconnect: forceReconnect)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -257,7 +265,8 @@ final class GatewayConnectionController {
|
||||
host: String,
|
||||
port: Int,
|
||||
useTLS: Bool,
|
||||
authOverride: ManualAuthOverride? = nil) async
|
||||
authOverride: ManualAuthOverride? = nil,
|
||||
forceReconnect: Bool = false) async
|
||||
{
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let token =
|
||||
@@ -319,27 +328,38 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
password: password,
|
||||
forceReconnect: forceReconnect)
|
||||
}
|
||||
|
||||
func connectLastKnown() async {
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS, forceReconnect: true)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
|
||||
_ = await self.connectSavedManualEndpointFallback()
|
||||
return
|
||||
}
|
||||
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild connect options from current local settings (caps/commands/permissions)
|
||||
/// and re-apply the active gateway config so capability changes take effect immediately.
|
||||
func refreshActiveGatewayRegistrationFromSettings() {
|
||||
Task { [weak self] in
|
||||
await self?.refreshActiveGatewayRegistrationFromSettingsAsync()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshActiveGatewayRegistrationFromSettingsAsync() async {
|
||||
guard let appModel else { return }
|
||||
guard let cfg = appModel.activeGatewayConnectConfig else { return }
|
||||
guard appModel.gatewayAutoReconnectEnabled else { return }
|
||||
|
||||
let nodeOptions = await self.makeConnectOptions(stableID: cfg.stableID)
|
||||
let refreshedConfig = GatewayConnectConfig(
|
||||
url: cfg.url,
|
||||
stableID: cfg.stableID,
|
||||
@@ -347,7 +367,7 @@ final class GatewayConnectionController {
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
||||
nodeOptions: nodeOptions)
|
||||
appModel.applyGatewayConnectConfig(refreshedConfig)
|
||||
}
|
||||
|
||||
@@ -522,32 +542,14 @@ final class GatewayConnectionController {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
if case let .manual(host, port, useTLS, stableID) = lastKnown {
|
||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil)
|
||||
else { return }
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard tlsParams != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
|
||||
self.startLastKnownAutoConnect(
|
||||
lastKnown,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@@ -582,6 +584,44 @@ final class GatewayConnectionController {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
_ = self.startSavedManualEndpointFallback()
|
||||
}
|
||||
|
||||
private func startLastKnownAutoConnect(
|
||||
_ lastKnown: GatewaySettingsStore.LastGatewayConnection,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?) -> Bool
|
||||
{
|
||||
switch lastKnown {
|
||||
case let .manual(host, port, useTLS, stableID):
|
||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil)
|
||||
else { return false }
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard tlsParams != nil else { return false }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
return true
|
||||
case .discovered:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptAutoReconnectIfNeeded() {
|
||||
@@ -594,6 +634,46 @@ final class GatewayConnectionController {
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
private func savedManualEndpointFallback(defaults: UserDefaults = .standard) -> SavedManualEndpoint? {
|
||||
guard defaults.bool(forKey: "gateway.autoconnect") else { return nil }
|
||||
guard defaults.bool(forKey: "gateway.manual.enabled") else { return nil }
|
||||
let host = defaults.string(forKey: "gateway.manual.host")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
|
||||
let configuredPort = defaults.integer(forKey: "gateway.manual.port")
|
||||
let configuredUseTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: configuredUseTLS)
|
||||
guard let resolvedPort = self.resolveManualPort(
|
||||
host: host,
|
||||
port: configuredPort,
|
||||
useTLS: resolvedUseTLS)
|
||||
else { return nil }
|
||||
|
||||
return SavedManualEndpoint(host: host, port: resolvedPort, useTLS: resolvedUseTLS)
|
||||
}
|
||||
|
||||
private func startSavedManualEndpointFallback() -> Bool {
|
||||
guard let endpoint = self.savedManualEndpointFallback() else { return false }
|
||||
self.didAutoConnect = true
|
||||
Task { [weak self] in
|
||||
await self?.connectManual(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
useTLS: endpoint.useTLS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func connectSavedManualEndpointFallback() async -> Bool {
|
||||
guard let endpoint = self.savedManualEndpointFallback() else { return false }
|
||||
await self.connectManual(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
useTLS: endpoint.useTLS)
|
||||
return true
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
let defaults = UserDefaults.standard
|
||||
let preferred = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@@ -615,16 +695,14 @@ final class GatewayConnectionController {
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?)
|
||||
password: String?,
|
||||
forceReconnect: Bool = false)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||
|
||||
Task { [weak appModel] in
|
||||
guard let appModel else { return }
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
Task { [weak self, weak appModel] in
|
||||
guard let self, let appModel else { return }
|
||||
let nodeOptions = await self.makeConnectOptions(stableID: gatewayStableID)
|
||||
let cfg = GatewayConnectConfig(
|
||||
url: url,
|
||||
stableID: gatewayStableID,
|
||||
@@ -632,8 +710,8 @@ final class GatewayConnectionController {
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
nodeOptions: connectOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg)
|
||||
nodeOptions: nodeOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg, forceReconnect: forceReconnect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,7 +902,9 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
let scheme = useTLS ? "wss" : "ws"
|
||||
var components = URLComponents()
|
||||
@@ -852,17 +932,18 @@ final class GatewayConnectionController {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
|
||||
private func makeConnectOptions(stableID: String?) async -> GatewayConnectOptions {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
||||
let permissions = await self.currentPermissions()
|
||||
|
||||
return GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands(),
|
||||
permissions: self.currentPermissions(),
|
||||
permissions: permissions,
|
||||
clientId: resolvedClientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
@@ -1000,14 +1081,16 @@ final class GatewayConnectionController {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentPermissions() -> [String: Bool] {
|
||||
private func currentPermissions() async -> [String: Bool] {
|
||||
var permissions: [String: Bool] = [:]
|
||||
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
permissions["location"] = Self.isLocationAuthorized(
|
||||
status: CLLocationManager().authorizationStatus)
|
||||
&& CLLocationManager.locationServicesEnabled()
|
||||
let locationStatus = CLLocationManager().authorizationStatus
|
||||
let locationServicesEnabled = await Self.locationServicesEnabled()
|
||||
permissions["location"] = Self.isLocationAvailable(
|
||||
servicesEnabled: locationServicesEnabled,
|
||||
status: locationStatus)
|
||||
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
||||
|
||||
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
@@ -1034,12 +1117,19 @@ final class GatewayConnectionController {
|
||||
return permissions
|
||||
}
|
||||
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
private static func locationServicesEnabled() async -> Bool {
|
||||
await Task.detached(priority: .utility) {
|
||||
CLLocationManager.locationServicesEnabled()
|
||||
}.value
|
||||
}
|
||||
|
||||
private static func isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
guard servicesEnabled else { return false }
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
true
|
||||
return true
|
||||
default:
|
||||
false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1066,8 +1156,12 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() -> [String: Bool] {
|
||||
self.currentPermissions()
|
||||
func _test_currentPermissions() async -> [String: Bool] {
|
||||
await self.currentPermissions()
|
||||
}
|
||||
|
||||
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
|
||||
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
@@ -1112,6 +1206,14 @@ extension GatewayConnectionController {
|
||||
func _test_resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
|
||||
self.resolveManualPort(host: host, port: port, useTLS: useTLS)
|
||||
}
|
||||
|
||||
func _test_savedManualEndpointFallback(
|
||||
defaults: UserDefaults = .standard) -> (host: String, port: Int, useTLS: Bool)?
|
||||
{
|
||||
self.savedManualEndpointFallback(defaults: defaults).map { endpoint in
|
||||
(host: endpoint.host, port: endpoint.port, useTLS: endpoint.useTLS)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GatewayProblemBanner: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
@@ -57,9 +59,15 @@ struct GatewayProblemBanner: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThickMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.18 : 0.08), radius: 18, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct HomeToolbar: View {
|
||||
var gateway: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: StatusPill.Activity?
|
||||
var brighten: Bool
|
||||
var talkButtonEnabled: Bool
|
||||
var talkActive: Bool
|
||||
var talkTint: Color
|
||||
var onStatusTap: () -> Void
|
||||
var onChatTap: () -> Void
|
||||
var onTalkTap: () -> Void
|
||||
var onSettingsTap: () -> Void
|
||||
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
|
||||
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HomeToolbarStatusButton(
|
||||
gateway: self.gateway,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.activity,
|
||||
brighten: self.brighten,
|
||||
onTap: self.onStatusTap)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HomeToolbarActionButton(
|
||||
systemImage: "text.bubble.fill",
|
||||
accessibilityLabel: "Chat",
|
||||
brighten: self.brighten,
|
||||
action: self.onChatTap)
|
||||
|
||||
if self.talkButtonEnabled {
|
||||
HomeToolbarActionButton(
|
||||
systemImage: self.talkActive ? "waveform.circle.fill" : "waveform.circle",
|
||||
accessibilityLabel: self.talkActive ? "Talk Mode On" : "Talk Mode Off",
|
||||
brighten: self.brighten,
|
||||
tint: self.talkTint,
|
||||
isActive: self.talkActive,
|
||||
action: self.onTalkTap)
|
||||
}
|
||||
|
||||
HomeToolbarActionButton(
|
||||
systemImage: "gearshape.fill",
|
||||
accessibilityLabel: "Settings",
|
||||
brighten: self.brighten,
|
||||
action: self.onSettingsTap)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .top) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.white.opacity(self.brighten ? 0.10 : 0.06),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkToolbarTray: View {
|
||||
var brighten: Bool
|
||||
var tint: Color
|
||||
var statusText: String
|
||||
var agentName: String
|
||||
var micLevel: Double
|
||||
var isListening: Bool
|
||||
var isSpeaking: Bool
|
||||
var isUserSpeechDetected: Bool
|
||||
var permissionState: TalkGatewayPermissionState
|
||||
var voiceModeTitle: String
|
||||
var voiceModeSubtitle: String?
|
||||
var onEnableTalk: () -> Void
|
||||
var onStopTalk: () -> Void
|
||||
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
private var state: TalkToolbarTrayState {
|
||||
TalkToolbarTrayState(
|
||||
statusText: self.statusText,
|
||||
isListening: self.isListening,
|
||||
isSpeaking: self.isSpeaking,
|
||||
isUserSpeechDetected: self.isUserSpeechDetected,
|
||||
permissionState: self.permissionState)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(self.tint.opacity(self.state.iconFillOpacity))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: self.state.systemImage)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(self.state.iconColor(tint: self.tint))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.state.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
if self.state.showsProgress {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
TalkWaveformView(
|
||||
mode: self.state.waveformMode(micLevel: self.micLevel),
|
||||
tint: self.state.waveformTint(tint: self.tint))
|
||||
.frame(width: 84, height: 18)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text(self.subtitle)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let voiceModeText = self.voiceModeText {
|
||||
Text(voiceModeText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
switch self.state.action {
|
||||
case .enable:
|
||||
Button(action: self.onEnableTalk) {
|
||||
Label("Enable Talk", systemImage: "key.fill")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
case .stop:
|
||||
Button(action: self.onStopTalk) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(self.brighten ? 0.10 : 0.18))
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.42 : 0.16),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Stop Talk")
|
||||
case .none:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.18 : 0.12)))
|
||||
.frame(height: self.contrast == .increased ? 1.0 : 0.6)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
self.tint.opacity(self.brighten ? 0.12 : 0.16),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing)
|
||||
.frame(height: 1)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("Talk Mode")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let voiceModeText {
|
||||
return "\(self.state.title), \(self.subtitle), \(voiceModeText)"
|
||||
}
|
||||
return "\(self.state.title), \(self.subtitle)"
|
||||
}
|
||||
|
||||
private var voiceModeText: String? {
|
||||
guard !self.state.prefersPermissionCopy else { return nil }
|
||||
let title = self.voiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty, title != "Not loaded" else { return nil }
|
||||
let subtitle = (self.voiceModeSubtitle ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return subtitle.isEmpty ? title : "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
let trimmedAgent = self.agentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if self.state.prefersPermissionCopy {
|
||||
return "Gateway approval needed"
|
||||
}
|
||||
if !trimmedAgent.isEmpty {
|
||||
return trimmedAgent
|
||||
}
|
||||
return "OpenClaw"
|
||||
}
|
||||
}
|
||||
|
||||
private enum TalkToolbarTrayAction {
|
||||
case none
|
||||
case enable
|
||||
case stop
|
||||
}
|
||||
|
||||
private enum TalkWaveformMode: Equatable {
|
||||
case level(Double)
|
||||
case inputSpeech
|
||||
case speaking
|
||||
case indeterminate
|
||||
case still
|
||||
}
|
||||
|
||||
private struct TalkToolbarTrayState: Equatable {
|
||||
let statusText: String
|
||||
let isListening: Bool
|
||||
let isSpeaking: Bool
|
||||
let isUserSpeechDetected: Bool
|
||||
let permissionState: TalkGatewayPermissionState
|
||||
|
||||
private var normalizedStatus: String {
|
||||
self.statusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "Gateway permission required"
|
||||
case .requestingUpgrade:
|
||||
return "Requesting approval"
|
||||
case .upgradeRequested:
|
||||
return "Approval requested"
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.isSpeaking { return "Speaking" }
|
||||
if self.isListening { return "Listening" }
|
||||
if self.normalizedStatus.contains("connecting") { return "Connecting" }
|
||||
if self.normalizedStatus.contains("thinking") { return "Asking OpenClaw" }
|
||||
if self.normalizedStatus == "ready" { return "Ready to talk" }
|
||||
if self.normalizedStatus.isEmpty || self.normalizedStatus == "off" { return "Talk" }
|
||||
return self.statusText
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
return "key.fill"
|
||||
case .requestingUpgrade:
|
||||
return "paperplane.fill"
|
||||
case .upgradeRequested:
|
||||
return "hourglass"
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.isSpeaking { return "speaker.wave.2.fill" }
|
||||
if self.isListening { return "mic.fill" }
|
||||
if self.normalizedStatus.contains("thinking") { return "sparkles" }
|
||||
if self.normalizedStatus.contains("connecting") { return "dot.radiowaves.left.and.right" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
var action: TalkToolbarTrayAction {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestFailed:
|
||||
.enable
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
.none
|
||||
default:
|
||||
.stop
|
||||
}
|
||||
}
|
||||
|
||||
var showsProgress: Bool {
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
true
|
||||
default:
|
||||
self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking")
|
||||
}
|
||||
}
|
||||
|
||||
var prefersPermissionCopy: Bool {
|
||||
switch self.permissionState {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var iconFillOpacity: Double {
|
||||
self.prefersPermissionCopy ? 0.18 : 0.24
|
||||
}
|
||||
|
||||
func iconColor(tint: Color) -> Color {
|
||||
switch self.permissionState {
|
||||
case .requestFailed:
|
||||
.red
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested:
|
||||
.orange
|
||||
default:
|
||||
tint
|
||||
}
|
||||
}
|
||||
|
||||
func waveformTint(tint: Color) -> Color {
|
||||
switch self.permissionState {
|
||||
case .requestFailed:
|
||||
.red
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested:
|
||||
.orange
|
||||
default:
|
||||
tint
|
||||
}
|
||||
}
|
||||
|
||||
func waveformMode(micLevel: Double) -> TalkWaveformMode {
|
||||
switch self.permissionState {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
return .indeterminate
|
||||
case .missingScope, .requestFailed:
|
||||
return .still
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if self.isSpeaking {
|
||||
return .speaking
|
||||
}
|
||||
if self.isListening, self.isUserSpeechDetected {
|
||||
return .inputSpeech
|
||||
}
|
||||
if self.isListening {
|
||||
return .level(micLevel)
|
||||
}
|
||||
if self.normalizedStatus.contains("connecting") || self.normalizedStatus.contains("thinking") {
|
||||
return .indeterminate
|
||||
}
|
||||
return .still
|
||||
}
|
||||
}
|
||||
|
||||
private struct TalkWaveformView: View {
|
||||
var mode: TalkWaveformMode
|
||||
var tint: Color
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
private let barCount = 14
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.periodic(from: .now, by: 1.0 / 24.0)) { timeline in
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
ForEach(0..<self.barCount, id: \.self) { index in
|
||||
Capsule(style: .continuous)
|
||||
.fill(self.tint.opacity(self.opacity(for: index)))
|
||||
.frame(width: 3, height: self.height(for: index, date: timeline.date))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, date: Date) -> CGFloat {
|
||||
let minimum: Double = 4
|
||||
let maximum: Double = 18
|
||||
let amplitude = self.amplitude(for: index, date: date)
|
||||
return CGFloat(minimum + ((maximum - minimum) * amplitude))
|
||||
}
|
||||
|
||||
private func opacity(for index: Int) -> Double {
|
||||
switch self.mode {
|
||||
case .still:
|
||||
index == self.barCount / 2 ? 0.64 : 0.32
|
||||
default:
|
||||
0.78
|
||||
}
|
||||
}
|
||||
|
||||
private func amplitude(for index: Int, date: Date) -> Double {
|
||||
if self.reduceMotion {
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
return min(max(level, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
return 0.72
|
||||
case .speaking:
|
||||
return 0.62
|
||||
case .indeterminate:
|
||||
return 0.34
|
||||
case .still:
|
||||
return 0.18
|
||||
}
|
||||
}
|
||||
|
||||
let t = date.timeIntervalSinceReferenceDate
|
||||
let phase = Double(index) * 0.52
|
||||
switch self.mode {
|
||||
case let .level(level):
|
||||
let clamped = min(max(level, 0), 1)
|
||||
let shaped = 0.12 + (0.88 * clamped)
|
||||
let variation = 0.72 + (0.28 * sin((t * 12.0) + phase))
|
||||
return min(max(shaped * variation, 0.10), 1.0)
|
||||
case .inputSpeech:
|
||||
let primary = 0.5 + (0.5 * sin((t * 14.0) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 5.0) + (phase * 1.35)))
|
||||
return min(max(0.16 + (0.60 * primary) + (0.24 * secondary), 0.14), 1.0)
|
||||
case .speaking:
|
||||
let wave = 0.5 + (0.5 * sin((t * 7.5) + phase))
|
||||
let secondary = 0.5 + (0.5 * sin((t * 3.0) + (phase * 0.7)))
|
||||
return min(max(0.18 + (0.58 * wave) + (0.24 * secondary), 0.12), 1.0)
|
||||
case .indeterminate:
|
||||
let center = (sin((t * 3.2) + phase) + 1) / 2
|
||||
return 0.16 + (0.42 * center)
|
||||
case .still:
|
||||
return index == self.barCount / 2 ? 0.32 : 0.16
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeToolbarStatusButton: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var gateway: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: StatusPill.Activity?
|
||||
var brighten: Bool
|
||||
var onTap: () -> Void
|
||||
|
||||
@State private var pulse: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onTap) {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if let activity {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.46 : (self.brighten ? 0.22 : 0.16)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.accessibilityHint(
|
||||
self.gateway == .connected
|
||||
? "Double tap for gateway actions"
|
||||
: "Double tap to open settings")
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.reduceMotion) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let activity {
|
||||
return "\(self.gateway.title), \(activity.title)"
|
||||
}
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for gateway: StatusPill.GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
|
||||
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
|
||||
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.pulse else { return }
|
||||
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
self.pulse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeToolbarActionButton: View {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
let systemImage: String
|
||||
let accessibilityLabel: String
|
||||
let brighten: Bool
|
||||
var tint: Color?
|
||||
var isActive: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
Image(systemName: self.systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(self.isActive ? (self.tint ?? .primary) : .primary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.black.opacity(self.brighten ? 0.12 : 0.18))
|
||||
.overlay {
|
||||
if let tint {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
tint.opacity(self.isActive ? 0.22 : 0.14),
|
||||
tint.opacity(self.isActive ? 0.08 : 0.04),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing))
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(
|
||||
(self.tint ?? .white).opacity(
|
||||
self.isActive
|
||||
? 0.34
|
||||
: (self.contrast == .increased ? 0.4 : (self.brighten ? 0.22 : 0.16))),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : (self.isActive ? 0.8 : 0.6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(self.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,6 @@
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -45,19 +43,21 @@
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_openclaw-gw._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
@@ -67,7 +67,7 @@
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>OpenClaw uses your location when you allow location sharing.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OpenClaw needs microphone access for voice wake.</string>
|
||||
<string>OpenClaw uses the microphone for realtime chat, voice wake, and push-to-talk.</string>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<string>OpenClaw may use motion data to support device-aware interactions and automations.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
@@ -75,9 +75,11 @@
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>OpenClawCanonicalVersion</key>
|
||||
<string>$(OPENCLAW_IOS_VERSION)</string>
|
||||
<key>OpenClawPushAPNsEnvironment</key>
|
||||
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||
<key>OpenClawPushDistribution</key>
|
||||
|
||||
@@ -111,6 +111,10 @@ final class NodeAppModel {
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
var isOperatorGatewayConnected: Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
@@ -127,6 +131,7 @@ final class NodeAppModel {
|
||||
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
private var focusedChatSessionKey: String?
|
||||
var selectedAgentId: String?
|
||||
var gatewayDefaultAgentId: String?
|
||||
var gatewayAgents: [AgentSummary] = []
|
||||
@@ -291,7 +296,6 @@ final class NodeAppModel {
|
||||
self.talkMode.attachGateway(self.operatorGateway)
|
||||
self.refreshLastShareEventFromRelay()
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
// Route through the coordinator so VoiceWake and Talk don't fight over the microphone.
|
||||
self.setTalkEnabled(talkEnabled)
|
||||
|
||||
// Wire up deep links from canvas taps
|
||||
@@ -452,7 +456,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
// Foreground recovery must actively restart the saved gateway config.
|
||||
// Disconnecting stale sockets alone can leave us idle if the old
|
||||
@@ -543,7 +547,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
if self.isBackgrounded {
|
||||
@@ -690,6 +694,11 @@ final class NodeAppModel {
|
||||
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "talk_permission_poll_connected")
|
||||
}
|
||||
|
||||
func setTalkSpeakerphoneEnabled(_ enabled: Bool) {
|
||||
UserDefaults.standard.set(enabled, forKey: TalkDefaults.speakerphoneEnabledKey)
|
||||
self.talkMode.applyAudioRoutePreferenceChanged()
|
||||
}
|
||||
|
||||
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
|
||||
guard mode != .off else { return true }
|
||||
let status = await self.locationService.ensureAuthorization(mode: mode)
|
||||
@@ -892,7 +901,7 @@ final class NodeAppModel {
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
@@ -1840,9 +1849,25 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
var chatSessionKey: String {
|
||||
if let focused = self.focusedChatSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!focused.isEmpty
|
||||
{
|
||||
return focused
|
||||
}
|
||||
// Keep chat aligned with the gateway's resolved main session key.
|
||||
// A hardcoded "ios" base creates synthetic placeholder sessions in the chat UI.
|
||||
self.mainSessionKey
|
||||
return self.mainSessionKey
|
||||
}
|
||||
|
||||
func openChat(sessionKey: String?) {
|
||||
self.focusChatSession(sessionKey)
|
||||
self.openChatRequestID &+= 1
|
||||
}
|
||||
|
||||
func focusChatSession(_ sessionKey: String?) {
|
||||
let trimmed = (sessionKey ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.focusedChatSessionKey = trimmed.isEmpty ? nil : trimmed
|
||||
self.talkMode.updateMainSessionKey(self.chatSessionKey)
|
||||
}
|
||||
|
||||
var activeAgentName: String {
|
||||
@@ -1864,13 +1889,13 @@ extension NodeAppModel {
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
connectOptions: GatewayConnectOptions)
|
||||
connectOptions: GatewayConnectOptions,
|
||||
forceReconnect: Bool = false)
|
||||
{
|
||||
let stableID = gatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveStableID = stableID.isEmpty ? url.absoluteString : stableID
|
||||
let sessionBox = tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
|
||||
self.activeGatewayConnectConfig = GatewayConnectConfig(
|
||||
let nextConfig = GatewayConnectConfig(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
tls: tls,
|
||||
@@ -1878,13 +1903,24 @@ extension NodeAppModel {
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
nodeOptions: connectOptions)
|
||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||
if self.shouldStartOperatorGatewayLoop(
|
||||
let operatorLoopRequired = self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
stableID: effectiveStableID)
|
||||
if let activeConfig = self.activeGatewayConnectConfig,
|
||||
activeConfig.hasSameConnectionInputs(as: nextConfig),
|
||||
self.nodeGatewayTask != nil,
|
||||
self.operatorGatewayTask != nil || !operatorLoopRequired,
|
||||
!forceReconnect
|
||||
{
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
self.activeGatewayConnectConfig = nextConfig
|
||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||
if operatorLoopRequired {
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: effectiveStableID,
|
||||
@@ -1908,8 +1944,7 @@ extension NodeAppModel {
|
||||
}
|
||||
|
||||
/// Preferred entry-point: apply a single config object and start both sessions.
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig) {
|
||||
self.activeGatewayConnectConfig = cfg
|
||||
func applyGatewayConnectConfig(_ cfg: GatewayConnectConfig, forceReconnect: Bool = false) {
|
||||
self.connectToGateway(
|
||||
url: cfg.url,
|
||||
// Preserve the caller-provided stableID (may be empty) and let connectToGateway
|
||||
@@ -1919,7 +1954,8 @@ extension NodeAppModel {
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
connectOptions: cfg.nodeOptions)
|
||||
connectOptions: cfg.nodeOptions,
|
||||
forceReconnect: forceReconnect)
|
||||
}
|
||||
|
||||
func disconnectGateway() {
|
||||
@@ -1945,7 +1981,7 @@ extension NodeAppModel {
|
||||
self.connectedGatewayID = nil
|
||||
self.activeGatewayConnectConfig = nil
|
||||
self.gatewayConnected = false
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
@@ -1968,7 +2004,7 @@ extension NodeAppModel {
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = stableID
|
||||
self.gatewayConnected = false
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.voiceWakeSyncTask?.cancel()
|
||||
self.voiceWakeSyncTask = nil
|
||||
LiveActivityManager.shared.endActivity(reason: "new_gateway_connect")
|
||||
@@ -2206,7 +2242,7 @@ extension NodeAppModel {
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.operatorConnected = true
|
||||
self.setOperatorConnected(true)
|
||||
self.forceOperatorTalkPermissionUpgradeRequest = false
|
||||
self.talkMode.updateGatewayConnected(true)
|
||||
}
|
||||
@@ -2224,7 +2260,7 @@ extension NodeAppModel {
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
await MainActor.run {
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
LiveActivityManager.shared.endActivity(reason: "operator_disconnected")
|
||||
}
|
||||
@@ -2491,7 +2527,7 @@ extension NodeAppModel {
|
||||
self.gatewayRemoteAddress = nil
|
||||
self.connectedGatewayID = nil
|
||||
self.gatewayConnected = false
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.seamColorHex = nil
|
||||
self.mainSessionBaseKey = "main"
|
||||
@@ -2566,6 +2602,11 @@ extension NodeAppModel {
|
||||
private func isOperatorConnected() async -> Bool {
|
||||
self.operatorConnected
|
||||
}
|
||||
|
||||
private func setOperatorConnected(_ connected: Bool) {
|
||||
self.operatorConnected = connected
|
||||
self.operatorStatusText = connected ? "Connected" : "Offline"
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
@@ -3908,7 +3949,7 @@ extension NodeAppModel {
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
self.stopGatewayHealthMonitor()
|
||||
|
||||
@@ -3977,7 +4018,7 @@ extension NodeAppModel {
|
||||
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
self.operatorConnected = false
|
||||
self.setOperatorConnected(false)
|
||||
self.gatewayConnected = false
|
||||
self.gatewayStatusText = "Reconnecting…"
|
||||
self.talkMode.updateGatewayConnected(false)
|
||||
|
||||
@@ -24,11 +24,16 @@ enum OnboardingStateStore {
|
||||
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
|
||||
|
||||
@MainActor
|
||||
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
|
||||
static func shouldPresentOnLaunch(
|
||||
appModel: NodeAppModel,
|
||||
defaults: UserDefaults = .standard,
|
||||
hasSavedGatewayConnection: Bool? = nil)
|
||||
-> Bool
|
||||
{
|
||||
if defaults.bool(forKey: self.completedDefaultsKey) { return false }
|
||||
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
|
||||
// should handle reconnecting, and users can always open onboarding manually if needed.
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
||||
let hasSavedGatewayConnection =
|
||||
hasSavedGatewayConnection ?? (GatewaySettingsStore.loadLastGatewayConnection() != nil)
|
||||
if hasSavedGatewayConnection { return false }
|
||||
return appModel.gatewayServerName == nil
|
||||
}
|
||||
|
||||
|
||||
186
apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift
Normal file
186
apps/ios/Sources/Onboarding/OnboardingWizardSteps.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingIntroStep: View {
|
||||
let onContinue: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "iphone.gen3")
|
||||
.font(.system(size: 60, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Connect to your gateway", systemImage: "link")
|
||||
Label("Choose device permissions", systemImage: "hand.raised")
|
||||
Label("Use OpenClaw from your phone", systemImage: "message.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Security notice")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"The connected OpenClaw agent can use device capabilities you enable, "
|
||||
+ "such as camera, microphone, photos, contacts, calendar, and location. "
|
||||
+ "Continue only if you trust the gateway and agent you connect to.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
self.onContinue()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWelcomeStep: View {
|
||||
let statusLine: String
|
||||
let onScanQRCode: () -> Void
|
||||
let onManualSetup: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connect Gateway")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How to pair")
|
||||
.font(.headline)
|
||||
Text("In your OpenClaw chat, run")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("/pair qr")
|
||||
.font(.system(.footnote, design: .monospaced).weight(.semibold))
|
||||
Text("Then scan the QR code here to connect this iPhone.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
self.onScanQRCode()
|
||||
} label: {
|
||||
Label("Scan QR Code", systemImage: "qrcode")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
self.onManualSetup()
|
||||
} label: {
|
||||
Text("Set Up Manually")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text(self.statusLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingModeRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(.body.weight(.semibold))
|
||||
Text(self.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -284,154 +284,19 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
|
||||
private var introStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "iphone.gen3")
|
||||
.font(.system(size: 60, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Connect to your gateway", systemImage: "link")
|
||||
Label("Choose device permissions", systemImage: "hand.raised")
|
||||
Label("Use OpenClaw from your phone", systemImage: "message.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Security notice")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"The connected OpenClaw agent can use device capabilities you enable, "
|
||||
+ "such as camera, microphone, photos, contacts, calendar, and location. "
|
||||
+ "Continue only if you trust the gateway and agent you connect to.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
self.advanceFromIntro()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
OnboardingIntroStep(onContinue: self.advanceFromIntro)
|
||||
}
|
||||
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connect Gateway")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How to pair")
|
||||
.font(.headline)
|
||||
Text("In your OpenClaw chat, run")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("/pair qr")
|
||||
.font(.system(.footnote, design: .monospaced).weight(.semibold))
|
||||
Text("Then scan the QR code here to connect this iPhone.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.showQRScanner = true
|
||||
} label: {
|
||||
Label("Scan QR Code", systemImage: "qrcode")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
self.step = .mode
|
||||
} label: {
|
||||
Text("Set Up Manually")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text(self.statusLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
OnboardingWelcomeStep(
|
||||
statusLine: self.statusLine,
|
||||
onScanQRCode: {
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.showQRScanner = true
|
||||
},
|
||||
onManualSetup: {
|
||||
self.step = .mode
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -702,7 +567,9 @@ struct OnboardingWizardView: View {
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OnboardingWizardView {
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section(title) {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
@@ -1057,28 +924,3 @@ struct OnboardingWizardView: View {
|
||||
await self.retryLastAttempt()
|
||||
}
|
||||
}
|
||||
|
||||
private struct OnboardingModeRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(.body.weight(.semibold))
|
||||
Text(self.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +602,8 @@ extension NodeAppModel {
|
||||
struct OpenClawApp: App {
|
||||
@State private var appModel: NodeAppModel
|
||||
@State private var gatewayController: GatewayConnectionController
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@UIApplicationDelegateAdaptor(OpenClawAppDelegate.self) private var appDelegate
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@@ -616,26 +618,62 @@ struct OpenClawApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootCanvas()
|
||||
RootTabs()
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
.task {
|
||||
self.appDelegate.appModel = self.appModel
|
||||
self.applyAppearancePreference()
|
||||
self.gatewayController.setScenePhase(self.scenePhase)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
Task { await self.appModel.handleDeepLink(url: url) }
|
||||
Task { await self.handleOpenURL(url) }
|
||||
}
|
||||
.onChange(of: self.appearancePreferenceRaw) { _, _ in
|
||||
self.applyAppearancePreference()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.appModel.setScenePhase(newValue)
|
||||
self.gatewayController.setScenePhase(newValue)
|
||||
self.appDelegate.scenePhaseChanged(newValue)
|
||||
self.applyAppearancePreference()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
AppAppearancePreference.launchArgumentPreference
|
||||
?? AppAppearancePreference(rawValue: self.appearancePreferenceRaw)
|
||||
?? .system
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyAppearancePreference() {
|
||||
let style = self.appearancePreference.userInterfaceStyle
|
||||
UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap(\.windows)
|
||||
.forEach { window in
|
||||
window.overrideUserInterfaceStyle = style
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawApp {
|
||||
@MainActor
|
||||
private func handleOpenURL(_ url: URL) async {
|
||||
guard let route = DeepLinkParser.parse(url) else { return }
|
||||
|
||||
switch route {
|
||||
case .agent, .dashboard:
|
||||
await self.appModel.handleDeepLink(url: url)
|
||||
case .gateway:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private static func installUncaughtExceptionLogger() {
|
||||
NSLog("OpenClaw: installing uncaught exception handler")
|
||||
NSSetUncaughtExceptionHandler { exception in
|
||||
|
||||
@@ -1,725 +0,0 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(\.colorScheme) private var systemColorScheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@State private var didEvaluateOnboarding: Bool = false
|
||||
@State private var didAutoOpenSettings: Bool = false
|
||||
|
||||
private enum PresentedSheet: Identifiable {
|
||||
case settings
|
||||
case chat
|
||||
case quickSetup
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .settings: 0
|
||||
case .chat: 1
|
||||
case .quickSetup: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
// On first run or explicit launch onboarding state, onboarding always wins.
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
// Settings auto-open is a recovery path for previously-connected installs only.
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
// If a gateway target is already configured (manual or last-known), skip quick setup.
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CanvasContent(
|
||||
systemColorScheme: self.systemColorScheme,
|
||||
gatewayStatus: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
voiceWakeToastText: self.voiceWakeToastText,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
cameraHUDKind: self.appModel.cameraHUDKind,
|
||||
openChat: {
|
||||
self.presentedSheet = .chat
|
||||
},
|
||||
openSettings: {
|
||||
self.presentedSheet = .settings
|
||||
},
|
||||
retryGatewayConnection: {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
},
|
||||
resetOnboarding: {
|
||||
self.resetOnboardingFromGatewayProblem()
|
||||
})
|
||||
.preferredColorScheme(.dark)
|
||||
|
||||
if self.appModel.cameraFlashNonce != 0 {
|
||||
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.deepLinkAgentPromptAlert()
|
||||
.execApprovalPromptDialog()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
SettingsTab()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
case .chat:
|
||||
ChatSheet(
|
||||
// Chat RPCs run on the operator session (read/write scopes).
|
||||
gateway: self.appModel.operatorSession,
|
||||
sessionKey: self.appModel.chatSessionKey,
|
||||
agentName: self.appModel.activeAgentName,
|
||||
userAccent: self.appModel.seamColor)
|
||||
case .quickSetup:
|
||||
GatewayQuickSetupSheet()
|
||||
.environment(self.appModel)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: self.$showOnboarding) {
|
||||
OnboardingWizardView(
|
||||
allowSkip: self.onboardingAllowSkip,
|
||||
onClose: {
|
||||
self.showOnboarding = false
|
||||
})
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.updateHomeCanvasState() }
|
||||
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updateIdleTimer()
|
||||
self.updateHomeCanvasState()
|
||||
guard newValue == .active else { return }
|
||||
Task {
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await MainActor.run {
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
}
|
||||
}
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in
|
||||
self.updateCanvasDebugStatus()
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
self.hasConnectedOnce = true
|
||||
OnboardingStateStore.markCompleted(mode: nil)
|
||||
}
|
||||
self.maybeAutoOpenSettings()
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.presentedSheet = .chat
|
||||
}
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
self.toastDismissTask?.cancel()
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
|
||||
self.toastDismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
self.toastDismissTask?.cancel()
|
||||
self.toastDismissTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private func updateIdleTimer() {
|
||||
UIApplication.shared.isIdleTimerDisabled = (self.scenePhase == .active && self.preventSleep)
|
||||
}
|
||||
|
||||
private func updateCanvasDebugStatus() {
|
||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
||||
guard self.canvasDebugStatusEnabled else { return }
|
||||
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func updateHomeCanvasState() {
|
||||
let payload = self.makeHomeCanvasPayload()
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
self.appModel.screen.updateHomeCanvasState(json: nil)
|
||||
return
|
||||
}
|
||||
self.appModel.screen.updateHomeCanvasState(json: json)
|
||||
}
|
||||
|
||||
private func makeHomeCanvasPayload() -> HomeCanvasPayload {
|
||||
let gatewayName = self.normalized(self.appModel.gatewayServerName)
|
||||
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
|
||||
let activeAgentID = self.resolveActiveAgentID()
|
||||
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
|
||||
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: "connected",
|
||||
eyebrow: "Connected to \(gatewayLabel)",
|
||||
title: "Your agents are ready",
|
||||
subtitle:
|
||||
"This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||
activeAgentCaption: "Selected on this phone",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(6)),
|
||||
footer: "The overview refreshes on reconnect and when the app returns to foreground.")
|
||||
case .connecting:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: "connecting",
|
||||
eyebrow: "Reconnecting",
|
||||
title: "OpenClaw is syncing back up",
|
||||
subtitle:
|
||||
"The gateway session is coming back online. "
|
||||
+ "Agent shortcuts should settle automatically in a moment.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Gateway session in progress",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer: "If the gateway is reachable, reconnect should complete without intervention.")
|
||||
case .error, .disconnected:
|
||||
return HomeCanvasPayload(
|
||||
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
|
||||
eyebrow: "Welcome to OpenClaw",
|
||||
title: "Your phone stays quiet until it is needed",
|
||||
subtitle:
|
||||
"Pair this device to your gateway to wake it only for real work, "
|
||||
+ "keep a live agent overview handy, and avoid battery-draining background loops.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: "Main",
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Connect to load your agents",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer:
|
||||
"When connected, the gateway can wake the phone with a silent push "
|
||||
+ "instead of holding an always-on session.")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveActiveAgentID() -> String {
|
||||
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
|
||||
if !selected.isEmpty {
|
||||
return selected
|
||||
}
|
||||
return self.resolveDefaultAgentID()
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func homeCanvasAgents(activeAgentID: String) -> [HomeCanvasAgentCard] {
|
||||
let defaultAgentID = self.resolveDefaultAgentID()
|
||||
let cards = self.appModel.gatewayAgents.map { agent -> HomeCanvasAgentCard in
|
||||
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
|
||||
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
|
||||
return HomeCanvasAgentCard(
|
||||
id: agent.id,
|
||||
name: self.homeCanvasName(for: agent),
|
||||
badge: self.homeCanvasBadge(for: agent),
|
||||
caption: isActive ? "Active on this phone" : (isDefault ? "Default agent" : "Ready"),
|
||||
isActive: isActive)
|
||||
}
|
||||
|
||||
return cards.sorted { lhs, rhs in
|
||||
if lhs.isActive != rhs.isActive {
|
||||
return lhs.isActive
|
||||
}
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func homeCanvasName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.homeCanvasName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func evaluateOnboardingPresentation(force: Bool) {
|
||||
if force {
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.didEvaluateOnboarding else { return }
|
||||
self.didEvaluateOnboarding = true
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
||||
switch route {
|
||||
case .none:
|
||||
break
|
||||
case .onboarding:
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
}
|
||||
|
||||
private func hasExistingGatewayConfig() -> Bool {
|
||||
if self.appModel.activeGatewayConnectConfig != nil { return true }
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
|
||||
|
||||
let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !preferredStableID.isEmpty { return true }
|
||||
|
||||
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.manualGatewayEnabled && !manualHost.isEmpty
|
||||
}
|
||||
|
||||
private func maybeAutoOpenSettings() {
|
||||
guard !self.didAutoOpenSettings else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
|
||||
private func maybeShowQuickSetup() {
|
||||
let shouldPresent = Self.shouldPresentQuickSetup(
|
||||
quickSetupDismissed: self.quickSetupDismissed,
|
||||
showOnboarding: self.showOnboarding,
|
||||
hasPresentedSheet: self.presentedSheet != nil,
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count)
|
||||
guard shouldPresent else { return }
|
||||
self.presentedSheet = .quickSetup
|
||||
}
|
||||
|
||||
private func resetOnboardingFromGatewayProblem() {
|
||||
GatewayOnboardingReset.reset(appModel: self.appModel, instanceId: self.instanceId)
|
||||
self.presentedSheet = nil
|
||||
self.onboardingAllowSkip = false
|
||||
self.showOnboarding = true
|
||||
}
|
||||
}
|
||||
|
||||
private struct HomeCanvasPayload: Codable {
|
||||
var gatewayState: String
|
||||
var eyebrow: String
|
||||
var title: String
|
||||
var subtitle: String
|
||||
var gatewayLabel: String
|
||||
var activeAgentName: String
|
||||
var activeAgentBadge: String
|
||||
var activeAgentCaption: String
|
||||
var agentCount: Int
|
||||
var agents: [HomeCanvasAgentCard]
|
||||
var footer: String
|
||||
}
|
||||
|
||||
private struct HomeCanvasAgentCard: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var badge: String
|
||||
var caption: String
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
private struct CanvasContent: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var showTalkPermissionPrompt: Bool = false
|
||||
@State private var showTalkPermissionTray: Bool = false
|
||||
var systemColorScheme: ColorScheme
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var voiceWakeToastText: String?
|
||||
var cameraHUDText: String?
|
||||
var cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
var retryGatewayConnection: () -> Void
|
||||
var resetOnboarding: () -> Void
|
||||
|
||||
private var brightenButtons: Bool {
|
||||
self.systemColorScheme == .light
|
||||
}
|
||||
|
||||
private var talkActive: Bool {
|
||||
(self.appModel.talkMode.isEnabled || self.talkEnabled) && !self.talkPermissionBlocksStart
|
||||
}
|
||||
|
||||
private var talkPermissionBlocksStart: Bool {
|
||||
self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction
|
||||
}
|
||||
|
||||
private var showTalkTray: Bool {
|
||||
self.talkActive ||
|
||||
self.showTalkPermissionTray ||
|
||||
self.appModel.talkMode.gatewayTalkPermissionState.isApprovalRequestInProgress
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScreenTab()
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
VStack(spacing: 0) {
|
||||
if self.showTalkTray {
|
||||
TalkToolbarTray(
|
||||
brighten: self.brightenButtons,
|
||||
tint: self.appModel.seamColor,
|
||||
statusText: self.appModel.talkMode.statusText,
|
||||
agentName: self.appModel.activeAgentName,
|
||||
micLevel: self.appModel.talkMode.micLevel,
|
||||
isListening: self.appModel.talkMode.isListening,
|
||||
isSpeaking: self.appModel.talkMode.isSpeaking,
|
||||
isUserSpeechDetected: self.appModel.talkMode.isUserSpeechDetected,
|
||||
permissionState: self.appModel.talkMode.gatewayTalkPermissionState,
|
||||
voiceModeTitle: self.appModel.talkMode.gatewayTalkVoiceModeTitle,
|
||||
voiceModeSubtitle: self.appModel.talkMode.gatewayTalkVoiceModeSubtitle,
|
||||
onEnableTalk: {
|
||||
self.showTalkPermissionPrompt = true
|
||||
},
|
||||
onStopTalk: {
|
||||
self.showTalkPermissionTray = false
|
||||
self.stopTalk()
|
||||
})
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
|
||||
HomeToolbar(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
talkButtonEnabled: self.talkButtonEnabled,
|
||||
talkActive: self.talkActive,
|
||||
talkTint: self.appModel.seamColor,
|
||||
onStatusTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
},
|
||||
onChatTap: {
|
||||
self.openChat()
|
||||
},
|
||||
onTalkTap: {
|
||||
self.handleTalkToolbarTap()
|
||||
},
|
||||
onSettingsTap: {
|
||||
self.openSettings()
|
||||
})
|
||||
}
|
||||
.animation(.spring(response: 0.28, dampingFraction: 0.86), value: self.showTalkTray)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(
|
||||
command: voiceWakeToastText,
|
||||
brighten: self.brightenButtons)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.openSettings() })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkPermissionPrompt) {
|
||||
NavigationStack {
|
||||
TalkPermissionPromptView(
|
||||
style: .sheet,
|
||||
onPermissionReady: {
|
||||
self.showTalkPermissionPrompt = false
|
||||
self.showTalkPermissionTray = false
|
||||
self.startTalk()
|
||||
})
|
||||
.padding()
|
||||
.navigationTitle("Enable Talk")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Not Now") {
|
||||
self.showTalkPermissionPrompt = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
.onAppear {
|
||||
// Keep the runtime talk state aligned with persisted toggle state on cold launch.
|
||||
if self.talkPermissionBlocksStart, self.talkEnabled || self.appModel.talkMode.isEnabled {
|
||||
self.stopTalk()
|
||||
} else if self.talkEnabled != self.appModel.talkMode.isEnabled {
|
||||
self.appModel.setTalkEnabled(self.talkEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.cameraHUDText,
|
||||
cameraHUDKind: self.cameraHUDKind)
|
||||
}
|
||||
|
||||
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
if problem.canTrustRotatedCertificate { return "Trust certificate" }
|
||||
if problem.suggestsOnboardingReset { return "Reset onboarding" }
|
||||
return problem.retryable ? "Retry" : "Open Settings"
|
||||
}
|
||||
|
||||
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
|
||||
if problem.canTrustRotatedCertificate {
|
||||
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
|
||||
} else if problem.suggestsOnboardingReset {
|
||||
self.resetOnboarding()
|
||||
} else if problem.retryable {
|
||||
self.retryGatewayConnection()
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTalkToolbarTap() {
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline tap active=\(self.talkActive) permissionBlocked=\(self.talkPermissionBlocksStart)")
|
||||
if self.talkActive {
|
||||
self.showTalkPermissionTray = false
|
||||
self.stopTalk()
|
||||
return
|
||||
}
|
||||
|
||||
if self.talkPermissionBlocksStart {
|
||||
self.stopTalk()
|
||||
self.showTalkPermissionTray = true
|
||||
Task {
|
||||
await self.appModel.pollTalkPermissionUpgrade()
|
||||
if !self.talkPermissionBlocksStart {
|
||||
self.showTalkPermissionTray = false
|
||||
self.startTalk()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.showTalkPermissionTray = false
|
||||
self.startTalk()
|
||||
}
|
||||
|
||||
private func startTalk() {
|
||||
GatewayDiagnostics.log("talk.timeline start requested from toolbar")
|
||||
self.talkEnabled = true
|
||||
self.appModel.setTalkEnabled(true)
|
||||
}
|
||||
|
||||
private func stopTalk() {
|
||||
GatewayDiagnostics.log("talk.timeline stop requested from toolbar")
|
||||
self.talkEnabled = false
|
||||
self.appModel.setTalkEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraFlashOverlay: View {
|
||||
var nonce: Int
|
||||
|
||||
@State private var opacity: CGFloat = 0
|
||||
@State private var task: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
Color.white
|
||||
.opacity(self.opacity)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
.onChange(of: self.nonce) { _, _ in
|
||||
self.task?.cancel()
|
||||
self.task = Task { @MainActor in
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
self.opacity = 0.85
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 110_000_000)
|
||||
withAnimation(.easeOut(duration: 0.32)) {
|
||||
self.opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,628 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct RootTabs: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@State private var selectedTab: Int = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@State private var selectedTab: AppTab = Self.initialTab
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@State private var didEvaluateOnboarding: Bool = false
|
||||
@State private var didAutoOpenSettings: Bool = false
|
||||
@State private var didApplyInitialAppearance: Bool = false
|
||||
@State private var didApplyInitialChatSession: Bool = false
|
||||
|
||||
private enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
private static var initialTab: AppTab {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
|
||||
return .control
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else {
|
||||
return .control
|
||||
}
|
||||
|
||||
switch arguments[valueIndex].lowercased() {
|
||||
case "chat":
|
||||
return .chat
|
||||
case "agent", "agents":
|
||||
return .agent
|
||||
case "settings":
|
||||
return .settings
|
||||
default:
|
||||
return .control
|
||||
}
|
||||
}
|
||||
|
||||
private static var initialChatSessionKey: String? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
let trimmed = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private enum PresentedSheet: Identifiable {
|
||||
case quickSetup
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .quickSetup: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.rootPresentation(
|
||||
self.rootLifecycle(
|
||||
self.rootOverlays(
|
||||
self.tabContent
|
||||
.tint(OpenClawBrand.accent))))
|
||||
}
|
||||
|
||||
private var tabContent: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
ScreenTab()
|
||||
.tabItem { Label("Screen", systemImage: "rectangle.and.hand.point.up.left") }
|
||||
.tag(0)
|
||||
CommandCenterTab(
|
||||
openChat: { self.selectedTab = .chat },
|
||||
openSettings: { self.selectedTab = .settings })
|
||||
.tabItem { Label("Command", systemImage: "target") }
|
||||
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
|
||||
.tag(AppTab.control)
|
||||
|
||||
VoiceTab()
|
||||
.tabItem { Label("Voice", systemImage: "mic") }
|
||||
.tag(1)
|
||||
ChatProTab()
|
||||
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
|
||||
.tag(AppTab.chat)
|
||||
|
||||
SettingsTab()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
||||
.tag(2)
|
||||
AgentProTab()
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
SettingsProTab()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(AppTab.settings)
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
StatusPill(
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else if self.appModel.lastGatewayProblem != nil {
|
||||
self.showGatewayProblemDetails = true
|
||||
} else {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
})
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
private func rootOverlays(_ content: some View) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem,
|
||||
self.gatewayStatus != .connected
|
||||
{
|
||||
GatewayProblemBanner(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
||||
},
|
||||
onShowDetails: {
|
||||
self.showGatewayProblemDetails = true
|
||||
})
|
||||
.padding(.horizontal, 12)
|
||||
.safeAreaPadding(.top, 10)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(command: voiceWakeToastText)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if let voiceWakeToastText, !voiceWakeToastText.isEmpty {
|
||||
VoiceWakeToast(command: voiceWakeToastText)
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, self.appModel.lastGatewayProblem == nil ? 58 : 132)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
self.toastDismissTask?.cancel()
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
.overlay {
|
||||
if self.appModel.cameraFlashNonce != 0 {
|
||||
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.toastDismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
private func rootLifecycle(_ content: some View) -> some View {
|
||||
self.rootRequestLifecycle(
|
||||
self.rootGatewayLifecycle(
|
||||
self.rootAppearLifecycle(
|
||||
self.rootVoiceWakeLifecycle(content))))
|
||||
}
|
||||
|
||||
private func rootVoiceWakeLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
self.toastDismissTask?.cancel()
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
|
||||
self.toastDismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
self.toastDismissTask?.cancel()
|
||||
self.toastDismissTask = nil
|
||||
}
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = 2 })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
private func rootAppearLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.updateCanvasState() }
|
||||
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onAppear { self.maybeShowQuickSetup() }
|
||||
.onAppear { self.applyInitialAppearanceIfNeeded() }
|
||||
.onAppear { self.applyInitialChatSessionIfNeeded() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.appModel.talkMode.isEnabled) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updateIdleTimer()
|
||||
self.updateHomeCanvasState()
|
||||
guard newValue == .active else { return }
|
||||
Task {
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await MainActor.run {
|
||||
self.updateHomeCanvasState()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
self.toastDismissTask?.cancel()
|
||||
self.toastDismissTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func rootGatewayLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
self.onboardingComplete = true
|
||||
self.hasConnectedOnce = true
|
||||
OnboardingStateStore.markCompleted(mode: nil)
|
||||
}
|
||||
self.maybeAutoOpenSettings()
|
||||
self.maybeShowQuickSetup()
|
||||
self.updateCanvasState()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasState() }
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasState() }
|
||||
.onChange(of: self.appModel.gatewayDisplayStatusText) { _, _ in self.updateCanvasState() }
|
||||
.onChange(of: self.appModel.homeCanvasRevision) { _, _ in self.updateHomeCanvasState() }
|
||||
.onChange(of: self.appModel.gatewayAgents.count) { _, _ in self.updateHomeCanvasState() }
|
||||
.onChange(of: self.appModel.selectedAgentId) { _, _ in self.updateHomeCanvasState() }
|
||||
.onChange(of: self.appModel.gatewayDefaultAgentId) { _, _ in self.updateHomeCanvasState() }
|
||||
.onChange(of: self.appModel.activeAgentName) { _, _ in self.updateHomeCanvasState() }
|
||||
.onChange(of: self.appModel.connectedGatewayID) { _, _ in
|
||||
self.updateCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
private func rootRequestLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.selectedTab = .chat
|
||||
}
|
||||
}
|
||||
|
||||
private func rootPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = .settings })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
self.handleGatewayProblemPrimaryAction(gatewayProblem)
|
||||
})
|
||||
}
|
||||
}
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .quickSetup:
|
||||
GatewayQuickSetupSheet()
|
||||
.environment(self.appModel)
|
||||
.environment(self.gatewayController)
|
||||
.openClawSheetChrome()
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: self.$showOnboarding) {
|
||||
OnboardingWizardView(
|
||||
allowSkip: self.onboardingAllowSkip,
|
||||
onClose: {
|
||||
self.showOnboarding = false
|
||||
})
|
||||
.environment(self.appModel)
|
||||
.environment(self.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.deepLinkAgentPromptAlert()
|
||||
.execApprovalPromptDialog()
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
AppAppearancePreference.launchArgumentPreference
|
||||
?? AppAppearancePreference(rawValue: self.appearancePreferenceRaw)
|
||||
?? .system
|
||||
}
|
||||
|
||||
private var gatewayStatus: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
cameraHUDKind: self.appModel.cameraHUDKind)
|
||||
private func updateIdleTimer() {
|
||||
UIApplication.shared.isIdleTimerDisabled =
|
||||
self.scenePhase == .active && (self.preventSleep || self.appModel.talkMode.isEnabled)
|
||||
}
|
||||
|
||||
private func updateCanvasState() {
|
||||
self.updateHomeCanvasState()
|
||||
self.updateCanvasDebugStatus()
|
||||
}
|
||||
|
||||
private func updateCanvasDebugStatus() {
|
||||
self.appModel.screen.setDebugStatusEnabled(self.canvasDebugStatusEnabled)
|
||||
guard self.canvasDebugStatusEnabled else { return }
|
||||
let title = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func updateHomeCanvasState() {
|
||||
let payload = self.makeHomeCanvasPayload()
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
self.appModel.screen.updateHomeCanvasState(json: nil)
|
||||
return
|
||||
}
|
||||
self.appModel.screen.updateHomeCanvasState(json: json)
|
||||
}
|
||||
|
||||
private func makeHomeCanvasPayload() -> RootTabsHomeCanvasPayload {
|
||||
let gatewayName = self.normalized(self.appModel.gatewayServerName)
|
||||
let gatewayAddress = self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
let gatewayLabel = gatewayName ?? gatewayAddress ?? "Gateway"
|
||||
let activeAgentID = self.resolveActiveAgentID()
|
||||
let agents = self.homeCanvasAgents(activeAgentID: activeAgentID)
|
||||
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
return RootTabsHomeCanvasPayload(
|
||||
gatewayState: "connected",
|
||||
eyebrow: "\(gatewayLabel) online",
|
||||
title: "Command center",
|
||||
subtitle:
|
||||
"Use Chat for code work, Talk for realtime voice, and gateway tools for approved device actions.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: agents.first(where: { $0.isActive })?.badge ?? "OC",
|
||||
activeAgentCaption: "Routes chat and talk",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(6)),
|
||||
footer: "OpenClaw only runs phone-side capabilities while the app is connected and permitted.")
|
||||
case .connecting:
|
||||
return RootTabsHomeCanvasPayload(
|
||||
gatewayState: "connecting",
|
||||
eyebrow: "Gateway handshake",
|
||||
title: "Reconnecting",
|
||||
subtitle:
|
||||
"Restoring the local node session, agent list, voice config, and device capability state.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: self.appModel.activeAgentName,
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Session in progress",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer: "If the gateway is reachable, the local node should recover without re-pairing.")
|
||||
case .error, .disconnected:
|
||||
return RootTabsHomeCanvasPayload(
|
||||
gatewayState: self.gatewayStatus == .error ? "error" : "offline",
|
||||
eyebrow: self.gatewayStatus == .error ? "Gateway needs attention" : "OpenClaw iOS",
|
||||
title: "Pair a gateway",
|
||||
subtitle:
|
||||
"Connect this phone as a local node for chat, realtime voice, share intake, and approved device tools.",
|
||||
gatewayLabel: gatewayLabel,
|
||||
activeAgentName: "Main",
|
||||
activeAgentBadge: "OC",
|
||||
activeAgentCaption: "Connect to load your agents",
|
||||
agentCount: agents.count,
|
||||
agents: Array(agents.prefix(4)),
|
||||
footer:
|
||||
"Use Settings to scan a pairing QR code or paste a setup code from your OpenClaw gateway.")
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveActiveAgentID() -> String {
|
||||
let selected = self.normalized(self.appModel.selectedAgentId) ?? ""
|
||||
if !selected.isEmpty {
|
||||
return selected
|
||||
}
|
||||
return self.resolveDefaultAgentID()
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func homeCanvasAgents(activeAgentID: String) -> [RootTabsHomeCanvasAgentCard] {
|
||||
let defaultAgentID = self.resolveDefaultAgentID()
|
||||
let cards = self.appModel.gatewayAgents.map { agent -> RootTabsHomeCanvasAgentCard in
|
||||
let isActive = !activeAgentID.isEmpty && agent.id == activeAgentID
|
||||
let isDefault = !defaultAgentID.isEmpty && agent.id == defaultAgentID
|
||||
return RootTabsHomeCanvasAgentCard(
|
||||
id: agent.id,
|
||||
name: self.homeCanvasName(for: agent),
|
||||
badge: self.homeCanvasBadge(for: agent),
|
||||
caption: isActive ? "Routed on this phone" : (isDefault ? "Gateway default" : "Available"),
|
||||
isActive: isActive)
|
||||
}
|
||||
|
||||
return cards.sorted { lhs, rhs in
|
||||
if lhs.isActive != rhs.isActive {
|
||||
return lhs.isActive
|
||||
}
|
||||
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func homeCanvasName(for agent: AgentSummary) -> String {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
let normalizedEmoji = normalized(emoji)
|
||||
{
|
||||
return normalizedEmoji
|
||||
}
|
||||
let words = self.homeCanvasName(for: agent)
|
||||
.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
|
||||
.prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func gatewayProblemPrimaryActionTitle(_ problem: GatewayConnectionProblem) -> String {
|
||||
problem.canTrustRotatedCertificate ? "Trust certificate" : "Open Settings"
|
||||
if problem.canTrustRotatedCertificate { return "Trust certificate" }
|
||||
return problem.retryable ? "Retry" : "Open Settings"
|
||||
}
|
||||
|
||||
private func handleGatewayProblemPrimaryAction(_ problem: GatewayConnectionProblem) {
|
||||
if problem.canTrustRotatedCertificate {
|
||||
Task { await self.gatewayController.trustRotatedGatewayCertificate(from: problem) }
|
||||
} else if problem.retryable {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
} else {
|
||||
self.selectedTab = 2
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
private func evaluateOnboardingPresentation(force: Bool) {
|
||||
if force {
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.didEvaluateOnboarding else { return }
|
||||
self.didEvaluateOnboarding = true
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
||||
switch route {
|
||||
case .none:
|
||||
break
|
||||
case .onboarding:
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
private func hasExistingGatewayConfig() -> Bool {
|
||||
if self.appModel.activeGatewayConnectConfig != nil { return true }
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
|
||||
|
||||
let preferredStableID = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !preferredStableID.isEmpty { return true }
|
||||
|
||||
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.manualGatewayEnabled && !manualHost.isEmpty
|
||||
}
|
||||
|
||||
private func maybeAutoOpenSettings() {
|
||||
guard !self.didAutoOpenSettings else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
|
||||
private func applyInitialChatSessionIfNeeded() {
|
||||
guard !self.didApplyInitialChatSession else { return }
|
||||
self.didApplyInitialChatSession = true
|
||||
self.appModel.focusChatSession(Self.initialChatSessionKey)
|
||||
}
|
||||
|
||||
private func applyInitialAppearanceIfNeeded() {
|
||||
guard !self.didApplyInitialAppearance else { return }
|
||||
self.didApplyInitialAppearance = true
|
||||
guard let preference = AppAppearancePreference.launchArgumentPreference else { return }
|
||||
self.appearancePreferenceRaw = preference.rawValue
|
||||
}
|
||||
|
||||
private func maybeShowQuickSetup() {
|
||||
let shouldPresent = Self.shouldPresentQuickSetup(
|
||||
quickSetupDismissed: self.quickSetupDismissed,
|
||||
showOnboarding: self.showOnboarding,
|
||||
hasPresentedSheet: self.presentedSheet != nil,
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
discoveredGatewayCount: self.gatewayController.gateways.count)
|
||||
guard shouldPresent else { return }
|
||||
self.presentedSheet = .quickSetup
|
||||
}
|
||||
}
|
||||
|
||||
private struct RootTabsHomeCanvasPayload: Codable {
|
||||
var gatewayState: String
|
||||
var eyebrow: String
|
||||
var title: String
|
||||
var subtitle: String
|
||||
var gatewayLabel: String
|
||||
var activeAgentName: String
|
||||
var activeAgentBadge: String
|
||||
var activeAgentCaption: String
|
||||
var agentCount: Int
|
||||
var agents: [RootTabsHomeCanvasAgentCard]
|
||||
var footer: String
|
||||
}
|
||||
|
||||
private struct RootTabsHomeCanvasAgentCard: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var badge: String
|
||||
var caption: String
|
||||
var isActive: Bool
|
||||
}
|
||||
|
||||
private struct RootCameraFlashOverlay: View {
|
||||
var nonce: Int
|
||||
|
||||
@State private var opacity: CGFloat = 0
|
||||
@State private var task: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
Color.white
|
||||
.opacity(self.opacity)
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(false)
|
||||
.onChange(of: self.nonce) { _, _ in
|
||||
self.task?.cancel()
|
||||
self.task = Task { @MainActor in
|
||||
withAnimation(.easeOut(duration: 0.08)) {
|
||||
self.opacity = 0.85
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 110_000_000)
|
||||
withAnimation(.easeOut(duration: 0.32)) {
|
||||
self.opacity = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
|
||||
var body: some View {
|
||||
RootCanvas()
|
||||
RootTabs()
|
||||
.preferredColorScheme(self.appearancePreference.colorScheme)
|
||||
}
|
||||
|
||||
private var appearancePreference: AppAppearancePreference {
|
||||
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct ScreenTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .top) {
|
||||
ScreenWebView(controller: self.appModel.screen)
|
||||
.ignoresSafeArea(.container, edges: [.top, .leading, .trailing])
|
||||
.overlay(alignment: .top) {
|
||||
if let errorText = self.appModel.screen.errorText,
|
||||
self.appModel.gatewayServerName == nil
|
||||
{
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.padding(10)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation is agent-driven; no local URL bar here.
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,16 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
enum GatewayDisplayState: Equatable {
|
||||
case connected
|
||||
case connecting
|
||||
case error
|
||||
case disconnected
|
||||
}
|
||||
|
||||
enum GatewayStatusBuilder {
|
||||
@MainActor
|
||||
static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
|
||||
static func build(appModel: NodeAppModel) -> GatewayDisplayState {
|
||||
self.build(
|
||||
gatewayServerName: appModel.gatewayServerName,
|
||||
lastGatewayProblem: appModel.lastGatewayProblem,
|
||||
@@ -13,7 +20,7 @@ enum GatewayStatusBuilder {
|
||||
static func build(
|
||||
gatewayServerName: String?,
|
||||
lastGatewayProblem: GatewayConnectionProblem?,
|
||||
gatewayStatusText: String) -> StatusPill.GatewayState
|
||||
gatewayStatusText: String) -> GatewayDisplayState
|
||||
{
|
||||
if gatewayServerName != nil { return .connected }
|
||||
if let lastGatewayProblem, lastGatewayProblem.pauseReconnect { return .error }
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum StatusActivityBuilder {
|
||||
@MainActor
|
||||
static func build(
|
||||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
cameraHUDText: String?,
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?) -> StatusPill.Activity?
|
||||
{
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
if let gatewayProblem = appModel.lastGatewayProblem {
|
||||
switch gatewayProblem.kind {
|
||||
case .pairingRequired,
|
||||
.pairingRoleUpgradeRequired,
|
||||
.pairingScopeUpgradeRequired,
|
||||
.pairingMetadataUpgradeRequired:
|
||||
return StatusPill.Activity(
|
||||
title: "Approval pending",
|
||||
systemImage: "person.crop.circle.badge.clock",
|
||||
tint: .orange)
|
||||
case .timeout, .connectionRefused, .reachabilityFailed, .websocketCancelled:
|
||||
return StatusPill.Activity(
|
||||
title: "Check network",
|
||||
systemImage: "wifi.exclamationmark",
|
||||
tint: .orange)
|
||||
default:
|
||||
if gatewayProblem.pauseReconnect {
|
||||
return StatusPill.Activity(
|
||||
title: "Action required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let gatewayStatus = 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 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 voiceWakeEnabled {
|
||||
let voiceStatus = 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 appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,36 +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))
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
case connecting
|
||||
case error
|
||||
case disconnected
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .connected: "Connected"
|
||||
case .connecting: "Connecting…"
|
||||
case .error: "Error"
|
||||
case .disconnected: "Offline"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .connected: .green
|
||||
case .connecting: .yellow
|
||||
case .error: .red
|
||||
case .disconnected: .gray
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Activity: Equatable {
|
||||
var title: String
|
||||
var systemImage: String
|
||||
var tint: Color?
|
||||
}
|
||||
|
||||
var gateway: GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
var activity: Activity?
|
||||
var compact: Bool = false
|
||||
var brighten: Bool = false
|
||||
var onTap: () -> Void
|
||||
|
||||
@State private var pulse: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.onTap) {
|
||||
HStack(spacing: self.compact ? 8 : 10) {
|
||||
HStack(spacing: self.compact ? 6 : 8) {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: self.compact ? 8 : 9, height: self.compact ? 8 : 9)
|
||||
.scaleEffect(
|
||||
self.gateway == .connecting && !self.reduceMotion
|
||||
? (self.pulse ? 1.15 : 0.85)
|
||||
: 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
if let activity {
|
||||
if !self.compact {
|
||||
Divider()
|
||||
.frame(height: 14)
|
||||
.opacity(0.35)
|
||||
}
|
||||
|
||||
HStack(spacing: self.compact ? 4 : 6) {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
if !self.compact {
|
||||
Text(activity.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font((self.compact ? Font.footnote : Font.subheadline).weight(.semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: self.compact ? 6 : 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.accessibilityHint("Double tap to open settings")
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.reduceMotion) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
|
||||
private var accessibilityValue: String {
|
||||
if let activity {
|
||||
return "\(self.gateway.title), \(activity.title)"
|
||||
}
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
|
||||
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
|
||||
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.pulse else { return }
|
||||
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
|
||||
self.pulse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeToast: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var command: String
|
||||
var brighten: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
@@ -16,7 +17,12 @@ struct VoiceWakeToast: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 10)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.proGlassSurface(
|
||||
fill: self.colorScheme == .dark ? Color.white.opacity(0.055) : Color.white.opacity(0.72),
|
||||
stroke: self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08),
|
||||
radius: 14)
|
||||
.accessibilityLabel("Voice Wake triggered")
|
||||
.accessibilityValue("Command: \(self.command)")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import OSLog
|
||||
private func makeRealtimeAudioTapBlock(
|
||||
inputSampleRate: Double,
|
||||
targetSampleRate: Double,
|
||||
onAudio: @escaping (Data, Double) -> Void) -> AVAudioNodeTapBlock
|
||||
onAudio: @escaping (Data, Double, Float) -> Void) -> AVAudioNodeTapBlock
|
||||
{
|
||||
{ buffer, _ in
|
||||
// This callback runs on Core Audio's realtime queue, not MainActor.
|
||||
@@ -18,7 +18,8 @@ private func makeRealtimeAudioTapBlock(
|
||||
targetSampleRate: targetSampleRate)
|
||||
guard !encoded.isEmpty else { return }
|
||||
let timestampMs = ProcessInfo.processInfo.systemUptime * 1000
|
||||
onAudio(encoded, timestampMs)
|
||||
let rms = RealtimeTalkRelaySession.rmsLevel(buffer: buffer)
|
||||
onAudio(encoded, timestampMs, rms)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +107,9 @@ final class RealtimeTalkRelaySession {
|
||||
private nonisolated static let expectedOutputEncoding = "pcm16"
|
||||
private nonisolated static let defaultSampleRateHz = 24000
|
||||
private nonisolated static let audioFrameBufferSize: AVAudioFrameCount = 2048
|
||||
private nonisolated static let bargeInRmsThreshold: Float = 0.08
|
||||
private nonisolated static let bargeInCooldownMs: Double = 900
|
||||
private nonisolated static let minOutputBeforeBargeInMs: Double = 250
|
||||
|
||||
private let gateway: GatewayNodeSession
|
||||
private let options: Options
|
||||
@@ -124,6 +128,14 @@ final class RealtimeTalkRelaySession {
|
||||
private var audioSender: RealtimeAudioSender?
|
||||
private var isClosed = false
|
||||
private var isOutputPlaying = false
|
||||
private var outputStartedAtMs: Double?
|
||||
private var lastBargeInAtMs: Double = 0
|
||||
private var micLogFrameCount = 0
|
||||
private var micLogByteCount = 0
|
||||
private var micLogMaxRms: Float = 0
|
||||
private var lastMicLogAtMs: Double = 0
|
||||
private var outputAudioChunkCount = 0
|
||||
private var outputAudioByteCount = 0
|
||||
|
||||
init(
|
||||
gateway: GatewayNodeSession,
|
||||
@@ -293,7 +305,8 @@ final class RealtimeTalkRelaySession {
|
||||
guard let base64 = payload["audioBase64"]?.stringValue,
|
||||
let data = Data(base64Encoded: base64)
|
||||
else { return }
|
||||
self.isOutputPlaying = true
|
||||
self.recordOutputAudioChunk(byteCount: data.count)
|
||||
self.markOutputAudioStarted(nowMs: ProcessInfo.processInfo.systemUptime * 1000)
|
||||
self.onSpeakingChanged(true)
|
||||
self.outputContinuation?.yield(data)
|
||||
case "clear":
|
||||
@@ -308,6 +321,7 @@ final class RealtimeTalkRelaySession {
|
||||
GatewayDiagnostics.log("talk realtime: error=\(Self.safeLogMessage(message))")
|
||||
self.onStatus(message)
|
||||
case "close":
|
||||
GatewayDiagnostics.log("talk realtime: close")
|
||||
self.onStatus("Ready")
|
||||
self.close(sendClose: false)
|
||||
default:
|
||||
@@ -315,9 +329,41 @@ final class RealtimeTalkRelaySession {
|
||||
}
|
||||
}
|
||||
|
||||
private func recordOutputAudioChunk(byteCount: Int) {
|
||||
self.outputAudioChunkCount += 1
|
||||
self.outputAudioByteCount += byteCount
|
||||
guard self.outputAudioChunkCount == 1 || self.outputAudioChunkCount % 20 == 0 else { return }
|
||||
GatewayDiagnostics.log(
|
||||
"talk realtime audio: chunks=\(self.outputAudioChunkCount) bytes=\(self.outputAudioByteCount)")
|
||||
}
|
||||
|
||||
private func markOutputAudioStarted(nowMs: Double) {
|
||||
if !self.isOutputPlaying {
|
||||
self.outputStartedAtMs = nowMs
|
||||
}
|
||||
self.isOutputPlaying = true
|
||||
}
|
||||
|
||||
private func handleInputLevelDuringOutput(_ rms: Float, timestampMs: Double) {
|
||||
guard self.isOutputPlaying else { return }
|
||||
guard rms >= Self.bargeInRmsThreshold else { return }
|
||||
if let outputStartedAtMs,
|
||||
timestampMs - outputStartedAtMs < Self.minOutputBeforeBargeInMs
|
||||
{
|
||||
return
|
||||
}
|
||||
guard timestampMs - self.lastBargeInAtMs >= Self.bargeInCooldownMs else { return }
|
||||
self.lastBargeInAtMs = timestampMs
|
||||
self.cancelOutput(reason: "barge-in")
|
||||
}
|
||||
|
||||
private func handleTranscriptEvent(_ payload: [String: AnyCodable]) {
|
||||
guard payload["final"]?.boolValue == true else { return }
|
||||
let isFinal = payload["final"]?.boolValue == true
|
||||
let role = payload["role"]?.stringValue ?? ""
|
||||
let charCount = payload["text"]?.stringValue?.count ?? 0
|
||||
GatewayDiagnostics.log(
|
||||
"talk realtime transcript: role=\(role.isEmpty ? "unknown" : role) final=\(isFinal) chars=\(charCount)")
|
||||
guard isFinal else { return }
|
||||
if role == "user" {
|
||||
self.onStatus("Thinking…")
|
||||
} else if role == "assistant" {
|
||||
@@ -488,9 +534,17 @@ final class RealtimeTalkRelaySession {
|
||||
let tapBlock = makeRealtimeAudioTapBlock(
|
||||
inputSampleRate: format.sampleRate,
|
||||
targetSampleRate: targetSampleRate)
|
||||
{ [weak self, audioSender = self.audioSender] encoded, timestampMs in
|
||||
{ [weak self, audioSender = self.audioSender] encoded, timestampMs, rms in
|
||||
guard let audioSender else { return }
|
||||
Task {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.recordMicrophoneFrame(byteCount: encoded.count, rms: rms, timestampMs: timestampMs)
|
||||
}
|
||||
if rms >= Self.bargeInRmsThreshold {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.handleInputLevelDuringOutput(rms, timestampMs: timestampMs)
|
||||
}
|
||||
}
|
||||
guard let message = await audioSender.send(encoded, timestampMs: timestampMs) else { return }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, !self.isClosed else { return }
|
||||
@@ -507,6 +561,21 @@ final class RealtimeTalkRelaySession {
|
||||
try self.audioEngine.start()
|
||||
}
|
||||
|
||||
private func recordMicrophoneFrame(byteCount: Int, rms: Float, timestampMs: Double) {
|
||||
guard !self.isClosed else { return }
|
||||
self.micLogFrameCount += 1
|
||||
self.micLogByteCount += byteCount
|
||||
self.micLogMaxRms = max(self.micLogMaxRms, rms)
|
||||
guard timestampMs - self.lastMicLogAtMs >= 1000 else { return }
|
||||
self.lastMicLogAtMs = timestampMs
|
||||
let maxRms = String(format: "%.4f", Double(self.micLogMaxRms))
|
||||
GatewayDiagnostics.log(
|
||||
"talk realtime mic: buffers=\(self.micLogFrameCount) bytes=\(self.micLogByteCount) maxRms=\(maxRms)")
|
||||
self.micLogFrameCount = 0
|
||||
self.micLogByteCount = 0
|
||||
self.micLogMaxRms = 0
|
||||
}
|
||||
|
||||
private func stopMicrophonePump() {
|
||||
self.audioEngine.inputNode.removeTap(onBus: 0)
|
||||
self.audioEngine.stop()
|
||||
@@ -524,12 +593,17 @@ final class RealtimeTalkRelaySession {
|
||||
if !result.finished, let interruptedAt = result.interruptedAt {
|
||||
self.logger.info("realtime output interrupted at \(interruptedAt, privacy: .public)s")
|
||||
}
|
||||
self.isOutputPlaying = false
|
||||
self.onSpeakingChanged(false)
|
||||
self.markOutputPlaybackFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func markOutputPlaybackFinished() {
|
||||
self.isOutputPlaying = false
|
||||
self.outputStartedAtMs = nil
|
||||
self.onSpeakingChanged(false)
|
||||
}
|
||||
|
||||
private func stopOutputPlayback() {
|
||||
self.outputContinuation?.finish()
|
||||
self.outputContinuation = nil
|
||||
@@ -537,6 +611,7 @@ final class RealtimeTalkRelaySession {
|
||||
self.outputTask = nil
|
||||
_ = self.pcmPlayer.stop()
|
||||
self.isOutputPlaying = false
|
||||
self.outputStartedAtMs = nil
|
||||
self.onSpeakingChanged(false)
|
||||
}
|
||||
|
||||
@@ -571,6 +646,26 @@ final class RealtimeTalkRelaySession {
|
||||
return data
|
||||
}
|
||||
|
||||
fileprivate nonisolated static func rmsLevel(buffer: AVAudioPCMBuffer) -> Float {
|
||||
guard let channelData = buffer.floatChannelData,
|
||||
buffer.frameLength > 0
|
||||
else { return 0 }
|
||||
let frameCount = Int(buffer.frameLength)
|
||||
let channelCount = max(1, Int(buffer.format.channelCount))
|
||||
var sumSquares: Float = 0
|
||||
var samples = 0
|
||||
for channel in 0..<channelCount {
|
||||
let values = channelData[channel]
|
||||
for index in 0..<frameCount {
|
||||
let sample = values[index]
|
||||
sumSquares += sample * sample
|
||||
samples += 1
|
||||
}
|
||||
}
|
||||
guard samples > 0 else { return 0 }
|
||||
return sqrt(sumSquares / Float(samples))
|
||||
}
|
||||
|
||||
private nonisolated static func safeLogMessage(_ value: String) -> String {
|
||||
let singleLine = value
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
@@ -586,3 +681,21 @@ final class RealtimeTalkRelaySession {
|
||||
return trimmed?.isEmpty == false ? trimmed : nil
|
||||
}
|
||||
}
|
||||
|
||||
extension RealtimeTalkRelaySession {
|
||||
func _test_markOutputAudioStarted(nowMs: Double) {
|
||||
self.markOutputAudioStarted(nowMs: nowMs)
|
||||
}
|
||||
|
||||
func _test_markOutputPlaybackFinished() {
|
||||
self.markOutputPlaybackFinished()
|
||||
}
|
||||
|
||||
func _test_outputStartedAtMs() -> Double? {
|
||||
self.outputStartedAtMs
|
||||
}
|
||||
|
||||
func _test_isOutputPlaying() -> Bool {
|
||||
self.isOutputPlaying
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
enum TalkDefaults {
|
||||
static let silenceTimeoutMs = 900
|
||||
static let speakerphoneEnabledKey = "talk.speakerphone.enabled"
|
||||
static let speakerphoneEnabledByDefault = true
|
||||
|
||||
static func speakerphoneEnabled(defaults: UserDefaults = .standard) -> Bool {
|
||||
guard defaults.object(forKey: self.speakerphoneEnabledKey) != nil else {
|
||||
return self.speakerphoneEnabledByDefault
|
||||
}
|
||||
return defaults.bool(forKey: self.speakerphoneEnabledKey)
|
||||
}
|
||||
}
|
||||
|
||||
63
apps/ios/Sources/Voice/TalkGatewayPermissionState.swift
Normal file
63
apps/ios/Sources/Voice/TalkGatewayPermissionState.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
enum TalkGatewayPermissionState: Equatable {
|
||||
case unknown
|
||||
case ready
|
||||
case missingScope(String)
|
||||
case requestingUpgrade
|
||||
case upgradeRequested(requestId: String?)
|
||||
case requestFailed(String)
|
||||
case apiKeyMissing
|
||||
case loadFailed(String)
|
||||
|
||||
var statusLabel: String {
|
||||
switch self {
|
||||
case .unknown:
|
||||
"Not checked"
|
||||
case .ready:
|
||||
"Ready"
|
||||
case let .missingScope(scope):
|
||||
"Missing \(scope)"
|
||||
case .requestingUpgrade:
|
||||
"Requesting approval"
|
||||
case .upgradeRequested:
|
||||
"Approval requested"
|
||||
case .requestFailed:
|
||||
"Request failed"
|
||||
case .apiKeyMissing:
|
||||
"API key missing"
|
||||
case .loadFailed:
|
||||
"Load failed"
|
||||
}
|
||||
}
|
||||
|
||||
var requiresTalkPermissionAction: Bool {
|
||||
switch self {
|
||||
case .missingScope, .requestingUpgrade, .upgradeRequested, .requestFailed:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var isApprovalRequestInProgress: Bool {
|
||||
switch self {
|
||||
case .requestingUpgrade, .upgradeRequested:
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
var failureMessage: String? {
|
||||
if case let .requestFailed(message) = self {
|
||||
return message
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var requestId: String? {
|
||||
if case let .upgradeRequested(requestId) = self {
|
||||
return requestId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -263,16 +263,16 @@ enum TalkModeGatewayConfigParser {
|
||||
let mode = Self.firstString(realtime, keys: ["mode"])?.lowercased()
|
||||
let transport = Self.firstString(realtime, keys: ["transport"])?.lowercased()
|
||||
let brain = Self.firstString(realtime, keys: ["brain"])?.lowercased()
|
||||
guard mode == "realtime", brain == nil || brain == "agent-consult" else {
|
||||
guard mode == "realtime" else {
|
||||
return .native
|
||||
}
|
||||
if transport == "gateway-relay" {
|
||||
return .realtimeRelay
|
||||
if transport == "managed-room" {
|
||||
return .native
|
||||
}
|
||||
if transport == nil || transport == "webrtc" {
|
||||
return .realtimeClient
|
||||
if brain != nil, brain != "agent-consult" {
|
||||
return .native
|
||||
}
|
||||
return .native
|
||||
return .realtimeRelay
|
||||
}
|
||||
|
||||
private static func singleRealtimeProviderId(_ providers: [String: AnyCodable]?) -> String? {
|
||||
|
||||
100
apps/ios/Sources/Voice/TalkModeManager+Permissions.swift
Normal file
100
apps/ios/Sources/Voice/TalkModeManager+Permissions.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
|
||||
extension TalkModeManager {
|
||||
nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioApplication.requestRecordPermission(completionHandler: { ok in
|
||||
completion(ok)
|
||||
})
|
||||
}
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
let status = SFSpeechRecognizer.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
SFSpeechRecognizer.requestAuthorization { authStatus in
|
||||
completion(authStatus == .authorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestPermissionWithTimeout(
|
||||
_ operation: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: 8,
|
||||
onTimeout: { NSError(domain: "TalkMode", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "permission request timed out",
|
||||
]) },
|
||||
operation: {
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
Task { @MainActor in
|
||||
operation { ok in
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .restricted:
|
||||
return "\(kind) permission restricted"
|
||||
case .notDetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .authorized:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if self.appModel.talkMode.gatewayTalkPermissionState.requiresTalkPermissionAction {
|
||||
Section {
|
||||
TalkPermissionPromptView(style: .card)
|
||||
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Status") {
|
||||
LabeledContent("Voice Wake", value: self.voiceWakeEnabled ? "Enabled" : "Disabled")
|
||||
LabeledContent("Listener", value: self.voiceWake.isListening ? "Listening" : "Idle")
|
||||
Text(self.voiceWake.statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent("Talk Mode", value: self.talkEnabled ? "Enabled" : "Disabled")
|
||||
LabeledContent(
|
||||
"Talk Permission",
|
||||
value: self.appModel.talkMode.gatewayTalkPermissionState.statusLabel)
|
||||
}
|
||||
|
||||
Section("Notes") {
|
||||
let triggers = self.voiceWake.activeTriggerWords
|
||||
Group {
|
||||
if triggers.isEmpty {
|
||||
Text("Add wake words in Settings.")
|
||||
} else if triggers.count == 1 {
|
||||
Text("Say “\(triggers[0]) …” to trigger.")
|
||||
} else if triggers.count == 2 {
|
||||
Text("Say “\(triggers[0]) …” or “\(triggers[1]) …” to trigger.")
|
||||
} else {
|
||||
Text("Say “\(triggers.joined(separator: " …”, “")) …” to trigger.")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Voice")
|
||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
.onChange(of: self.talkEnabled) { _, newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,33 @@
|
||||
Sources/Calendar/CalendarService.swift
|
||||
Sources/Camera/CameraController.swift
|
||||
Sources/Capabilities/NodeCapabilityRouter.swift
|
||||
Sources/Chat/ChatSheet.swift
|
||||
Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/Contacts/ContactsService.swift
|
||||
Sources/Device/DeviceInfoHelper.swift
|
||||
Sources/Device/DeviceStatusService.swift
|
||||
Sources/Device/NetworkStatusService.swift
|
||||
Sources/Device/NodeDisplayName.swift
|
||||
Sources/Design/OpenClawBrand.swift
|
||||
Sources/Design/AgentProDreamingDestination.swift
|
||||
Sources/Design/AgentProNodesDestination.swift
|
||||
Sources/Design/AgentProTab.swift
|
||||
Sources/Design/ChatProTab.swift
|
||||
Sources/Design/CommandCenterTab.swift
|
||||
Sources/Design/OpenClawProComponents.swift
|
||||
Sources/Design/OpenClawProScreens.swift
|
||||
Sources/Design/SettingsProTab.swift
|
||||
Sources/Design/SettingsProTabSupport.swift
|
||||
Sources/Design/SettingsProTabSections.swift
|
||||
Sources/Design/SettingsProTabActions.swift
|
||||
Sources/Design/CommandCenterSupport.swift
|
||||
Sources/Design/AgentProTab+Overview.swift
|
||||
Sources/Design/AgentProTab+Destinations.swift
|
||||
Sources/Design/AgentProTab+Skills.swift
|
||||
Sources/Design/AgentProTab+Cron.swift
|
||||
Sources/Design/AgentProTab+Usage.swift
|
||||
Sources/Design/AgentProTab+DetailComponents.swift
|
||||
Sources/Design/AgentProTab+GatewayData.swift
|
||||
Sources/Design/AgentProModels.swift
|
||||
Sources/EventKit/EventKitAuthorization.swift
|
||||
Sources/Gateway/DeepLinkAgentPromptAlert.swift
|
||||
Sources/Gateway/ExecApprovalPromptDialog.swift
|
||||
@@ -24,7 +44,6 @@ Sources/Gateway/GatewaySettingsStore.swift
|
||||
Sources/Gateway/GatewayTrustPromptAlert.swift
|
||||
Sources/Gateway/KeychainStore.swift
|
||||
Sources/Gateway/TCPProbe.swift
|
||||
Sources/HomeToolbar.swift
|
||||
Sources/LiveActivity/LiveActivityManager.swift
|
||||
Sources/LiveActivity/OpenClawActivityAttributes.swift
|
||||
Sources/Location/LocationService.swift
|
||||
@@ -38,6 +57,7 @@ Sources/Motion/MotionService.swift
|
||||
Sources/Onboarding/GatewayOnboardingReset.swift
|
||||
Sources/Onboarding/GatewayOnboardingView.swift
|
||||
Sources/Onboarding/OnboardingStateStore.swift
|
||||
Sources/Onboarding/OnboardingWizardSteps.swift
|
||||
Sources/Onboarding/OnboardingWizardView.swift
|
||||
Sources/Onboarding/QRScannerView.swift
|
||||
Sources/OpenClawApp.swift
|
||||
@@ -49,12 +69,10 @@ Sources/Push/PushRegistrationManager.swift
|
||||
Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
Sources/Screen/ScreenTab.swift
|
||||
Sources/Screen/ScreenWebView.swift
|
||||
Sources/Services/NodeServiceProtocols.swift
|
||||
Sources/Services/NotificationService.swift
|
||||
@@ -64,22 +82,20 @@ Sources/Services/WatchMessagingService.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
Sources/Status/GatewayActionsDialog.swift
|
||||
Sources/Status/GatewayStatusBuilder.swift
|
||||
Sources/Status/StatusActivityBuilder.swift
|
||||
Sources/Status/StatusGlassCard.swift
|
||||
Sources/Status/StatusPill.swift
|
||||
Sources/Status/VoiceWakeToast.swift
|
||||
Sources/Voice/TalkGatewayPermissionState.swift
|
||||
Sources/Voice/TalkDefaults.swift
|
||||
Sources/Voice/RealtimeTalkRelaySession.swift
|
||||
Sources/Voice/TalkModeGatewayConfig.swift
|
||||
Sources/Voice/TalkModeManager.swift
|
||||
Sources/Voice/TalkModeManager+Permissions.swift
|
||||
Sources/Voice/TalkPermissionPromptView.swift
|
||||
Sources/Voice/TalkRealtimeClientSession.swift
|
||||
Sources/Voice/TalkRealtimeWebRTCSession.swift
|
||||
Sources/Voice/TalkSpeechLocale.swift
|
||||
Sources/Voice/VoiceTab.swift
|
||||
Sources/Voice/VoiceWakeManager.swift
|
||||
Sources/Voice/VoiceWakePreferences.swift
|
||||
ShareExtension/ShareViewController.swift
|
||||
@@ -100,6 +116,8 @@ WatchExtension/Sources/WatchInboxView.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatView.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+Attachments.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+SessionKeys.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/AnyCodable.swift
|
||||
../shared/OpenClawKit/Sources/OpenClawKit/BonjourEscapes.swift
|
||||
|
||||
@@ -53,6 +53,21 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func locationPermissionRequiresGlobalServicesAndAppAuthorization() {
|
||||
#expect(GatewayConnectionController._test_isLocationAvailable(
|
||||
servicesEnabled: true,
|
||||
status: .authorizedWhenInUse))
|
||||
#expect(GatewayConnectionController._test_isLocationAvailable(
|
||||
servicesEnabled: true,
|
||||
status: .authorizedAlways))
|
||||
#expect(!GatewayConnectionController._test_isLocationAvailable(
|
||||
servicesEnabled: false,
|
||||
status: .authorizedAlways))
|
||||
#expect(!GatewayConnectionController._test_isLocationAvailable(
|
||||
servicesEnabled: true,
|
||||
status: .denied))
|
||||
}
|
||||
|
||||
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
@@ -130,6 +145,96 @@ import UIKit
|
||||
storedOperatorScopes: []))
|
||||
}
|
||||
|
||||
@Test @MainActor func savedManualEndpointFallbackUsesOnboardingHostWhenAutoConnectIsEnabled() {
|
||||
withUserDefaults([
|
||||
"gateway.autoconnect": true,
|
||||
"gateway.manual.enabled": true,
|
||||
"gateway.manual.host": "forges-mac-mini.taila96df5.ts.net",
|
||||
"gateway.manual.port": 0,
|
||||
"gateway.manual.tls": false,
|
||||
"node.instanceId": "ios-test",
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let endpoint = controller._test_savedManualEndpointFallback()
|
||||
|
||||
#expect(endpoint?.host == "forges-mac-mini.taila96df5.ts.net")
|
||||
#expect(endpoint?.port == 443)
|
||||
#expect(endpoint?.useTLS == true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func savedManualEndpointFallbackRequiresManualGatewayEnabled() {
|
||||
withUserDefaults([
|
||||
"gateway.autoconnect": true,
|
||||
"gateway.manual.enabled": false,
|
||||
"gateway.manual.host": "forges-mac-mini.taila96df5.ts.net",
|
||||
"gateway.manual.port": 443,
|
||||
"gateway.manual.tls": true,
|
||||
"node.instanceId": "ios-test",
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
#expect(controller._test_savedManualEndpointFallback() == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func savedManualEndpointFallbackRequiresAutoConnect() {
|
||||
withUserDefaults([
|
||||
"gateway.autoconnect": false,
|
||||
"gateway.manual.enabled": true,
|
||||
"gateway.manual.host": "forges-mac-mini.taila96df5.ts.net",
|
||||
"gateway.manual.port": 443,
|
||||
"gateway.manual.tls": true,
|
||||
"node.instanceId": "ios-test",
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
#expect(controller._test_savedManualEndpointFallback() == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func gatewayConnectConfigMatchesEquivalentInputs() {
|
||||
let lhs = Self.makeGatewayConnectConfig()
|
||||
let rhs = GatewayConnectConfig(
|
||||
url: lhs.url,
|
||||
stableID: lhs.stableID,
|
||||
tls: lhs.tls,
|
||||
token: lhs.token,
|
||||
bootstrapToken: lhs.bootstrapToken,
|
||||
password: lhs.password,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["canvas", "screen"],
|
||||
commands: ["location.get", "notify"],
|
||||
permissions: ["screen": true],
|
||||
clientId: "ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "Phone"))
|
||||
|
||||
#expect(lhs.hasSameConnectionInputs(as: rhs))
|
||||
}
|
||||
|
||||
@Test @MainActor func applyingDifferentGatewayConfigReconnectsActiveTasks() {
|
||||
let appModel = NodeAppModel()
|
||||
defer { appModel.disconnectGateway() }
|
||||
let first = Self.makeGatewayConnectConfig(
|
||||
url: URL(string: "wss://first.gateway.example.com")!,
|
||||
stableID: "manual|first.gateway.example.com|443")
|
||||
let second = Self.makeGatewayConnectConfig(
|
||||
url: URL(string: "wss://second.gateway.example.com")!,
|
||||
stableID: "manual|second.gateway.example.com|443")
|
||||
|
||||
appModel.applyGatewayConnectConfig(first)
|
||||
appModel.applyGatewayConnectConfig(second)
|
||||
|
||||
#expect(appModel.connectedGatewayID == second.stableID)
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
@@ -177,4 +282,30 @@ import UIKit
|
||||
#expect(loaded == nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeGatewayConnectConfig(
|
||||
url: URL = URL(string: "wss://gateway.example.com")!,
|
||||
stableID: String = "manual|gateway.example.com|443") -> GatewayConnectConfig
|
||||
{
|
||||
GatewayConnectConfig(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
tls: GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: "abc",
|
||||
allowTOFU: false,
|
||||
storeKey: stableID),
|
||||
token: "token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["screen", "canvas"],
|
||||
commands: ["notify", "location.get"],
|
||||
permissions: ["screen": true],
|
||||
clientId: "ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "Phone"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,61 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct IOSGatewayChatTransportTests {
|
||||
private func object(from json: String) throws -> [String: Any] {
|
||||
let data = try #require(json.data(using: .utf8))
|
||||
let value = try JSONSerialization.jsonObject(with: data)
|
||||
return try #require(value as? [String: Any])
|
||||
}
|
||||
|
||||
@Test func agentWaitTreatsSuccessAsCompletion() {
|
||||
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus("success"))
|
||||
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus(" ok "))
|
||||
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus("completed"))
|
||||
#expect(IOSGatewayChatTransport.isAgentWaitCompletionStatus("succeeded"))
|
||||
#expect(!IOSGatewayChatTransport.isAgentWaitCompletionStatus("timeout"))
|
||||
#expect(!IOSGatewayChatTransport.isAgentWaitCompletionStatus("failed"))
|
||||
}
|
||||
|
||||
@Test func agentWaitTimeoutAddsGatewayMargin() {
|
||||
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 1) == 6)
|
||||
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 1000) == 6)
|
||||
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 30000) == 35)
|
||||
}
|
||||
|
||||
@Test func agentWaitCompletionDecodesFallbackRunId() throws {
|
||||
let data = Data(#"{"status":"completed"}"#.utf8)
|
||||
let completion = try IOSGatewayChatTransport.decodeAgentWaitCompletion(data, fallbackRunId: "run-local")
|
||||
#expect(completion.runId == "run-local")
|
||||
#expect(completion.status == "completed")
|
||||
#expect(completion.completed)
|
||||
}
|
||||
|
||||
@Test func listSessionsParamsIncludeGlobalSessionsButNotUnknown() throws {
|
||||
let params = try self.object(from: IOSGatewayChatTransport.makeListSessionsParamsJSON(limit: 12))
|
||||
#expect(params["includeGlobal"] as? Bool == true)
|
||||
#expect(params["includeUnknown"] as? Bool == false)
|
||||
#expect(params["limit"] as? Int == 12)
|
||||
}
|
||||
|
||||
@Test func chatSendParamsOmitEmptyAttachmentsAndKeepSessionFields() throws {
|
||||
let params = try self.object(
|
||||
from: IOSGatewayChatTransport.makeChatSendParamsJSON(
|
||||
sessionKey: "agent:main",
|
||||
message: "hello",
|
||||
thinking: "low",
|
||||
idempotencyKey: "send-1",
|
||||
attachments: []))
|
||||
#expect(params["sessionKey"] as? String == "agent:main")
|
||||
#expect(params["message"] as? String == "hello")
|
||||
#expect(params["thinking"] as? String == "low")
|
||||
#expect(params["idempotencyKey"] as? String == "send-1")
|
||||
#expect(params["timeoutMs"] as? Int == IOSGatewayChatTransport.defaultChatSendTimeoutMs)
|
||||
#expect(params["attachments"] == nil)
|
||||
}
|
||||
|
||||
@Test func requestsFailFastWhenGatewayNotConnected() async {
|
||||
let gateway = GatewayNodeSession()
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@@ -32,6 +32,14 @@ private func makeAgentDeepLinkURL(
|
||||
return components.url!
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mountScreen(_ screen: ScreenController) throws -> ScreenWebViewCoordinator {
|
||||
let coordinator = ScreenWebViewCoordinator(controller: screen)
|
||||
_ = coordinator.makeContainerView()
|
||||
_ = try #require(coordinator.managedWebView)
|
||||
return coordinator
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockWatchMessagingService: @preconcurrency WatchMessagingServicing, @unchecked Sendable {
|
||||
var currentStatus = WatchMessagingStatus(
|
||||
@@ -79,7 +87,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
|
||||
func sendNotification(id: String, params: OpenClawWatchNotifyParams) async throws -> WatchNotificationSendResult {
|
||||
self.lastSent = (id: id, params: params)
|
||||
if let sendError = self.sendError {
|
||||
if let sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
@@ -89,7 +97,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
_ message: OpenClawWatchExecApprovalPromptMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalPrompt = message
|
||||
if let sendError = self.sendError {
|
||||
if let sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
@@ -99,7 +107,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
_ message: OpenClawWatchExecApprovalResolvedMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalResolved = message
|
||||
if let sendError = self.sendError {
|
||||
if let sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
@@ -109,7 +117,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
_ message: OpenClawWatchExecApprovalExpiredMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalExpired = message
|
||||
if let sendError = self.sendError {
|
||||
if let sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
@@ -119,7 +127,7 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
_ message: OpenClawWatchExecApprovalSnapshotMessage) async throws -> WatchNotificationSendResult
|
||||
{
|
||||
self.lastSentExecApprovalSnapshot = message
|
||||
if let sendError = self.sendError {
|
||||
if let sendError {
|
||||
throw sendError
|
||||
}
|
||||
return self.nextSendResult
|
||||
@@ -188,6 +196,16 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel.chatSessionKey == "main")
|
||||
}
|
||||
|
||||
@Test @MainActor func initPreservesSavedTalkModePreference() {
|
||||
withUserDefaults(["talk.enabled": true]) {
|
||||
let talkMode = TalkModeManager(allowSimulatorCapture: true)
|
||||
let appModel = NodeAppModel(talkMode: talkMode)
|
||||
|
||||
#expect(UserDefaults.standard.bool(forKey: "talk.enabled"))
|
||||
#expect(appModel.talkMode.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func chatSessionKeyUsesAgentScopedKeyForNonDefaultAgent() {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayDefaultAgentId = "main"
|
||||
@@ -198,8 +216,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
|
||||
@Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-1",
|
||||
commandText: "echo first",
|
||||
@@ -214,8 +232,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(firstPrompt.commandText == "echo first")
|
||||
#expect(firstPrompt.allowsAllowAlways == false)
|
||||
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-2",
|
||||
commandText: "echo second",
|
||||
@@ -236,8 +254,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
|
||||
@Test @MainActor func dismissPendingExecApprovalPromptByIdLeavesDifferentPromptVisible() throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-active",
|
||||
commandText: "echo keep",
|
||||
@@ -280,9 +298,9 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
@Test @MainActor func watchExecApprovalSnapshotRequestPublishesCachedApprovalsInBackground() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-snapshot",
|
||||
commandText: "echo from watch",
|
||||
@@ -308,9 +326,9 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
@Test @MainActor func watchExecApprovalSnapshotRequestSkipsForegroundRecovery() async throws {
|
||||
let watchService = MockWatchMessagingService()
|
||||
let appModel = NodeAppModel(watchMessagingService: watchService)
|
||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60_000
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
let futureExpiryMs = Int(Date().timeIntervalSince1970 * 1000) + 60000
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-foreground-skip",
|
||||
commandText: "echo foreground",
|
||||
@@ -351,8 +369,8 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
appModel._test_recordPendingWatchExecApprovalRecoveryID("approval-watch-clear")
|
||||
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs() == ["approval-watch-clear"])
|
||||
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
try appModel._test_presentExecApprovalPrompt(
|
||||
#require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-watch-clear",
|
||||
commandText: "echo clear",
|
||||
@@ -360,7 +378,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60_000)))
|
||||
expiresAtMs: Int(Date().timeIntervalSince1970 * 1000) + 60000)))
|
||||
|
||||
#expect(appModel._test_pendingWatchExecApprovalRecoveryIDs().isEmpty)
|
||||
}
|
||||
@@ -385,28 +403,23 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(
|
||||
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "watch_request",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
isBackgrounded: true))
|
||||
#expect(
|
||||
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "push_request",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
isBackgrounded: true))
|
||||
#expect(
|
||||
NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "watch_resolve",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
isBackgrounded: true))
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "direct",
|
||||
isBackgrounded: true)
|
||||
)
|
||||
isBackgrounded: true))
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldUseBackgroundAwareExecApprovalReconnect(
|
||||
sourceReason: "watch_request",
|
||||
isBackgrounded: false)
|
||||
)
|
||||
isBackgrounded: false))
|
||||
}
|
||||
|
||||
@Test func watchExecApprovalHydrateFetchesOnlyMissingIDs() {
|
||||
@@ -429,29 +442,25 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
hasStoredOperatorToken: true)
|
||||
)
|
||||
hasStoredOperatorToken: true))
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
hasStoredOperatorToken: false)
|
||||
)
|
||||
hasStoredOperatorToken: false))
|
||||
#expect(
|
||||
NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: nil,
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
hasStoredOperatorToken: true)
|
||||
)
|
||||
hasStoredOperatorToken: true))
|
||||
#expect(
|
||||
NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
token: "shared-token",
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
hasStoredOperatorToken: false)
|
||||
)
|
||||
hasStoredOperatorToken: false))
|
||||
}
|
||||
|
||||
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
|
||||
@@ -463,9 +472,9 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(center.requestAuthorizationCalls == 1)
|
||||
}
|
||||
|
||||
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
|
||||
let config = GatewayConnectConfig(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() throws {
|
||||
let config = try GatewayConnectConfig(
|
||||
url: #require(URL(string: "wss://gateway.example")),
|
||||
stableID: "test-gateway",
|
||||
tls: nil,
|
||||
token: nil,
|
||||
@@ -540,6 +549,9 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
|
||||
@Test @MainActor func handleInvokeCanvasCommandsUpdateScreen() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let coordinator = try mountScreen(appModel.screen)
|
||||
defer { coordinator.teardown() }
|
||||
|
||||
appModel.screen.navigate(to: "http://example.com")
|
||||
|
||||
let present = BridgeInvokeRequest(id: "present", command: OpenClawCanvasCommand.present.rawValue)
|
||||
@@ -566,7 +578,12 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
id: "eval",
|
||||
command: OpenClawCanvasCommand.evalJS.rawValue,
|
||||
paramsJSON: evalJSON)
|
||||
let evalRes = await appModel._test_handleInvoke(eval)
|
||||
var evalRes = await appModel._test_handleInvoke(eval)
|
||||
let deadline = ContinuousClock().now.advanced(by: .seconds(3))
|
||||
while evalRes.ok != true, ContinuousClock().now < deadline {
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
evalRes = await appModel._test_handleInvoke(eval)
|
||||
}
|
||||
#expect(evalRes.ok == true)
|
||||
let payloadData = try #require(evalRes.payloadJSON?.data(using: .utf8))
|
||||
let payload = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
@@ -583,8 +600,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
(
|
||||
id: "pending-nav-1",
|
||||
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON
|
||||
),
|
||||
paramsJSON: navJSON),
|
||||
])
|
||||
|
||||
#expect(appModel.screen.urlString == "http://example.com/")
|
||||
@@ -601,8 +617,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
(
|
||||
id: "pending-nav-bg",
|
||||
command: OpenClawCanvasCommand.navigate.rawValue,
|
||||
paramsJSON: navJSON
|
||||
),
|
||||
paramsJSON: navJSON),
|
||||
])
|
||||
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
@@ -817,17 +832,17 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel._test_queuedWatchReplyCount() == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async {
|
||||
@Test @MainActor func handleDeepLinkSetsErrorWhenNotConnected() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let url = URL(string: "openclaw://agent?message=hello")!
|
||||
let url = try #require(URL(string: "openclaw://agent?message=hello"))
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.screen.errorText?.contains("Gateway not connected") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async {
|
||||
@Test @MainActor func handleDeepLinkRejectsOversizedMessage() async throws {
|
||||
let appModel = NodeAppModel()
|
||||
let msg = String(repeating: "a", count: 20001)
|
||||
let url = URL(string: "openclaw://agent?message=\(msg)")!
|
||||
let url = try #require(URL(string: "openclaw://agent?message=\(msg)"))
|
||||
await appModel.handleDeepLink(url: url)
|
||||
#expect(appModel.screen.errorText?.contains("Deep link too large") == true)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ import Testing
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
}
|
||||
|
||||
@Test @MainActor func doesNotPresentWhenConnected() {
|
||||
@@ -20,7 +23,23 @@ import Testing
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = "gateway"
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
}
|
||||
|
||||
@Test @MainActor func doesNotPresentForSavedGatewayBeforeReconnectCompletes() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: true))
|
||||
}
|
||||
|
||||
@Test @MainActor func markCompletedPersistsMode() {
|
||||
@@ -33,10 +52,16 @@ import Testing
|
||||
|
||||
OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
|
||||
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
|
||||
OnboardingStateStore.markIncomplete(defaults: defaults)
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
}
|
||||
|
||||
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
|
||||
@@ -63,7 +88,10 @@ import Testing
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(
|
||||
appModel: appModel,
|
||||
defaults: defaults,
|
||||
hasSavedGatewayConnection: false))
|
||||
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .homeNetwork)
|
||||
}
|
||||
|
||||
40
apps/ios/Tests/RealtimeTalkRelaySessionTests.swift
Normal file
40
apps/ios/Tests/RealtimeTalkRelaySessionTests.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying {
|
||||
func play(stream: AsyncThrowingStream<Data, Error>, sampleRate: Double) async -> StreamingPlaybackResult {
|
||||
fatalError("Playback is not used by this test")
|
||||
}
|
||||
|
||||
func stop() -> Double? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite struct RealtimeTalkRelaySessionTests {
|
||||
@Test func outputPlaybackFinishClearsBargeInStartTime() {
|
||||
var speakingStates: [Bool] = []
|
||||
let session = RealtimeTalkRelaySession(
|
||||
gateway: GatewayNodeSession(),
|
||||
options: .init(sessionKey: "main", provider: nil, model: nil, voice: nil),
|
||||
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
||||
onStatus: { _ in },
|
||||
onSpeakingChanged: { speakingStates.append($0) })
|
||||
|
||||
session._test_markOutputAudioStarted(nowMs: 100)
|
||||
#expect(session._test_isOutputPlaying())
|
||||
#expect(session._test_outputStartedAtMs() == 100)
|
||||
|
||||
session._test_markOutputPlaybackFinished()
|
||||
#expect(!session._test_isOutputPlaying())
|
||||
#expect(session._test_outputStartedAtMs() == nil)
|
||||
#expect(speakingStates == [false])
|
||||
|
||||
session._test_markOutputAudioStarted(nowMs: 500)
|
||||
#expect(session._test_outputStartedAtMs() == 500)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct RootCanvasPresentationTests {
|
||||
@MainActor
|
||||
@Suite struct RootTabsPresentationTests {
|
||||
@Test func quickSetupDoesNotPresentWhenGatewayAlreadyConfigured() {
|
||||
let shouldPresent = RootCanvas.shouldPresentQuickSetup(
|
||||
let shouldPresent = RootTabs.shouldPresentQuickSetup(
|
||||
quickSetupDismissed: false,
|
||||
showOnboarding: false,
|
||||
hasPresentedSheet: false,
|
||||
@@ -15,7 +16,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func quickSetupPresentsForFreshInstallWithDiscoveredGateway() {
|
||||
let shouldPresent = RootCanvas.shouldPresentQuickSetup(
|
||||
let shouldPresent = RootTabs.shouldPresentQuickSetup(
|
||||
quickSetupDismissed: false,
|
||||
showOnboarding: false,
|
||||
hasPresentedSheet: false,
|
||||
@@ -27,7 +28,7 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func quickSetupDoesNotPresentWhenAlreadyConnected() {
|
||||
let shouldPresent = RootCanvas.shouldPresentQuickSetup(
|
||||
let shouldPresent = RootTabs.shouldPresentQuickSetup(
|
||||
quickSetupDismissed: false,
|
||||
showOnboarding: false,
|
||||
hasPresentedSheet: false,
|
||||
@@ -2,6 +2,34 @@ import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct SettingsNetworkingHelpersTests {
|
||||
@Test func diagnosticsIssuesNameEachReviewerVisibleCheck() {
|
||||
#expect(
|
||||
SettingsDiagnostics.issues(
|
||||
gatewayConnected: false,
|
||||
discoveredGatewayCount: 0,
|
||||
talkConfigLoaded: false,
|
||||
notificationStatusText: "Not Set") == [
|
||||
.gatewayOffline,
|
||||
.discoveryUnavailable,
|
||||
.notificationsUnavailable,
|
||||
])
|
||||
}
|
||||
|
||||
@Test func diagnosticsIssuesRequireTalkConfigOnlyAfterGatewayConnects() {
|
||||
#expect(
|
||||
SettingsDiagnostics.issues(
|
||||
gatewayConnected: true,
|
||||
discoveredGatewayCount: 1,
|
||||
talkConfigLoaded: false,
|
||||
notificationStatusText: "Allowed") == [.talkConfigMissing])
|
||||
#expect(
|
||||
SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: true,
|
||||
discoveredGatewayCount: 1,
|
||||
talkConfigLoaded: true,
|
||||
notificationStatusText: "Allowed") == 0)
|
||||
}
|
||||
|
||||
@Test func parseHostPortParsesIPv4() {
|
||||
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
|
||||
@Suite struct ShareToAgentDeepLinkTests {
|
||||
@@ -37,12 +37,14 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func buildURLReturnsNilWhenPayloadEmpty() {
|
||||
ShareToAgentSettings.saveDefaultInstruction(nil)
|
||||
let payload = SharedContentPayload(title: nil, url: nil, text: nil)
|
||||
#expect(ShareToAgentDeepLink.buildURL(from: payload) == nil)
|
||||
}
|
||||
|
||||
@Test func shareInstructionSettingsRoundTrip() {
|
||||
let value = "Focus on booking constraints and alternatives."
|
||||
ShareToAgentSettings.saveDefaultInstruction(nil)
|
||||
ShareToAgentSettings.saveDefaultInstruction(value)
|
||||
defer { ShareToAgentSettings.saveDefaultInstruction(nil) }
|
||||
|
||||
|
||||
@@ -14,21 +14,11 @@ import UIKit
|
||||
return window
|
||||
}
|
||||
|
||||
@Test @MainActor func statusPillConnectingBuildsAViewHierarchy() {
|
||||
let root = StatusPill(gateway: .connecting, voiceWakeEnabled: true, brighten: true) {}
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func statusPillDisconnectedBuildsAViewHierarchy() {
|
||||
let root = StatusPill(gateway: .disconnected, voiceWakeEnabled: false) {}
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func settingsTabBuildsAViewHierarchy() {
|
||||
@Test @MainActor func settingsProTabBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = SettingsTab()
|
||||
let root = SettingsProTab()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
@@ -36,6 +26,21 @@ import UIKit
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func settingsProTabBuildsInLightAndDarkMode() {
|
||||
for scheme in [ColorScheme.light, ColorScheme.dark] {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = SettingsProTab()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.preferredColorScheme(scheme)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
@@ -48,16 +53,6 @@ import UIKit
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func voiceTabBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
|
||||
let root = VoiceTab()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||
@@ -65,15 +60,6 @@ import UIKit
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func chatSheetBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let gateway = GatewayNodeSession()
|
||||
let root = ChatSheet(gateway: gateway, sessionKey: "test")
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func voiceWakeToastBuildsAViewHierarchy() {
|
||||
let root = VoiceWakeToast(command: "openclaw: do something")
|
||||
_ = Self.host(root)
|
||||
|
||||
@@ -70,7 +70,7 @@ import Testing
|
||||
defaultRealtimeModelIdFallback: "gpt-realtime-2",
|
||||
defaultSilenceTimeoutMs: 900)
|
||||
|
||||
#expect(parsed.executionMode == .realtimeClient)
|
||||
#expect(parsed.executionMode == .realtimeRelay)
|
||||
#expect(parsed.realtimeProvider == "openai")
|
||||
#expect(parsed.realtimeModelId == "gpt-realtime-2")
|
||||
}
|
||||
@@ -172,7 +172,18 @@ import Testing
|
||||
#expect(descriptor.subtitle == "Native • en-US")
|
||||
}
|
||||
|
||||
@Test func usesRealtimeClientModeForWebRTCTransport() {
|
||||
@Test func openAIRealtimeSelectionFallbackKeepsGatewayRelayDefaults() {
|
||||
let manager = TalkModeManager(allowSimulatorCapture: true)
|
||||
|
||||
manager._test_applyOpenAIRealtimeSelectionDefaults()
|
||||
|
||||
#expect(manager._test_executionMode() == .realtimeRelay)
|
||||
#expect(manager._test_realtimeProvider() == "openai")
|
||||
#expect(manager._test_realtimeModelId() == "gpt-realtime-2")
|
||||
#expect(manager._test_gatewayTalkUsesRealtimeRelay())
|
||||
}
|
||||
|
||||
@Test func mapsWebRTCRealtimeTransportToGatewayRelayOnIOS() {
|
||||
let config: [String: Any] = [
|
||||
"talk": [
|
||||
"realtime": [
|
||||
@@ -190,7 +201,76 @@ import Testing
|
||||
defaultRealtimeModelIdFallback: "gpt-realtime-2",
|
||||
defaultSilenceTimeoutMs: 900)
|
||||
|
||||
#expect(parsed.executionMode == .realtimeClient)
|
||||
#expect(parsed.executionMode == .realtimeRelay)
|
||||
}
|
||||
|
||||
@Test func parsesRedactedGatewayRealtimeConfig() {
|
||||
let config: [String: Any] = [
|
||||
"talk": [
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"apiKey": "__OPENCLAW_REDACTED__",
|
||||
"voiceId": "bIHbv24MWmeRgasZH58o",
|
||||
],
|
||||
],
|
||||
"realtime": [
|
||||
"provider": "openai",
|
||||
"providers": [
|
||||
"openai": [
|
||||
"model": "gpt-realtime-2",
|
||||
"voice": "cedar",
|
||||
],
|
||||
],
|
||||
"model": "gpt-realtime-2",
|
||||
"mode": "realtime",
|
||||
"transport": "webrtc",
|
||||
"brain": "agent-consult",
|
||||
],
|
||||
"provider": "elevenlabs",
|
||||
"resolved": [
|
||||
"provider": "elevenlabs",
|
||||
"config": [
|
||||
"apiKey": "__OPENCLAW_REDACTED__",
|
||||
"voiceId": "bIHbv24MWmeRgasZH58o",
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: config,
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
defaultRealtimeModelIdFallback: "gpt-realtime-2",
|
||||
defaultSilenceTimeoutMs: 900)
|
||||
|
||||
#expect(parsed.activeProvider == "elevenlabs")
|
||||
#expect(parsed.executionMode == .realtimeRelay)
|
||||
#expect(parsed.realtimeProvider == "openai")
|
||||
#expect(parsed.realtimeModelId == "gpt-realtime-2")
|
||||
#expect(parsed.realtimeVoiceId == "cedar")
|
||||
#expect(parsed.rawConfigApiKey == "__OPENCLAW_REDACTED__")
|
||||
}
|
||||
|
||||
@Test func leavesNativeModeForManagedRoomRealtimeTransport() {
|
||||
let config: [String: Any] = [
|
||||
"talk": [
|
||||
"realtime": [
|
||||
"provider": "openai",
|
||||
"mode": "realtime",
|
||||
"transport": "managed-room",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let parsed = TalkModeGatewayConfigParser.parse(
|
||||
config: config,
|
||||
defaultProvider: "elevenlabs",
|
||||
defaultModelIdFallback: "eleven_v3",
|
||||
defaultRealtimeModelIdFallback: "gpt-realtime-2",
|
||||
defaultSilenceTimeoutMs: 900)
|
||||
|
||||
#expect(parsed.executionMode == .native)
|
||||
}
|
||||
|
||||
@Test func detectsPCMFormatRejectionFromElevenLabsError() {
|
||||
|
||||
@@ -137,6 +137,7 @@ targets:
|
||||
NSLocalNetworkUsageDescription: OpenClaw discovers and connects to your OpenClaw gateway on the local network.
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
NSAllowsLocalNetworking: true
|
||||
NSBonjourServices:
|
||||
- _openclaw-gw._tcp
|
||||
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
|
||||
@@ -146,11 +147,11 @@ targets:
|
||||
NSContactsUsageDescription: OpenClaw uses your contacts so you can search and reference people while using the assistant.
|
||||
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
|
||||
NSMicrophoneUsageDescription: OpenClaw uses the microphone for realtime chat, voice wake, and push-to-talk.
|
||||
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
|
||||
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
|
||||
NSRemindersFullAccessUsageDescription: OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for talk mode and voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
|
||||
|
||||
@@ -7,11 +7,46 @@ import PhotosUI
|
||||
import UniformTypeIdentifiers
|
||||
#endif
|
||||
|
||||
public struct OpenClawChatTalkControl {
|
||||
public var isEnabled: Bool
|
||||
public var isListening: Bool
|
||||
public var isSpeaking: Bool
|
||||
public var isGatewayConnected: Bool
|
||||
public var statusText: String
|
||||
public var providerLabel: String
|
||||
public var toggle: @MainActor (_ sessionKey: String) -> Void
|
||||
|
||||
public init(
|
||||
isEnabled: Bool,
|
||||
isListening: Bool,
|
||||
isSpeaking: Bool,
|
||||
isGatewayConnected: Bool,
|
||||
statusText: String,
|
||||
providerLabel: String,
|
||||
toggle: @escaping @MainActor (_ sessionKey: String) -> Void)
|
||||
{
|
||||
self.isEnabled = isEnabled
|
||||
self.isListening = isListening
|
||||
self.isSpeaking = isSpeaking
|
||||
self.isGatewayConnected = isGatewayConnected
|
||||
self.statusText = statusText
|
||||
self.providerLabel = providerLabel
|
||||
self.toggle = toggle
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct OpenClawChatComposer: View {
|
||||
@Bindable var viewModel: OpenClawChatViewModel
|
||||
let style: OpenClawChatView.Style
|
||||
let showsSessionSwitcher: Bool
|
||||
let userAccent: Color?
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let composerChrome: OpenClawChatView.ComposerChrome
|
||||
let messagePlaceholder: String?
|
||||
let talkControl: OpenClawChatTalkControl?
|
||||
|
||||
#if !os(macOS)
|
||||
@State private var pickerItems: [PhotosPickerItem] = []
|
||||
@@ -23,19 +58,7 @@ struct OpenClawChatComposer: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if self.showsToolbar {
|
||||
HStack(spacing: 6) {
|
||||
if self.showsSessionSwitcher {
|
||||
self.sessionPicker
|
||||
}
|
||||
if self.viewModel.showsModelPicker {
|
||||
self.modelPicker
|
||||
}
|
||||
self.thinkingPicker
|
||||
Spacer()
|
||||
self.refreshButton
|
||||
self.attachmentPicker
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
self.composerToolbar
|
||||
}
|
||||
|
||||
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
|
||||
@@ -46,35 +69,37 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
.padding(self.composerPadding)
|
||||
.background {
|
||||
let cornerRadius: CGFloat = 18
|
||||
if self.composerChrome == .full {
|
||||
let cornerRadius: CGFloat = 18
|
||||
|
||||
#if os(macOS)
|
||||
if self.style == .standard {
|
||||
let shape = UnevenRoundedRectangle(
|
||||
cornerRadii: RectangleCornerRadii(
|
||||
topLeading: 0,
|
||||
bottomLeading: cornerRadius,
|
||||
bottomTrailing: cornerRadius,
|
||||
topTrailing: 0),
|
||||
style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
} else {
|
||||
#if os(macOS)
|
||||
if self.style == .standard {
|
||||
let shape = UnevenRoundedRectangle(
|
||||
cornerRadii: RectangleCornerRadii(
|
||||
topLeading: 0,
|
||||
bottomLeading: cornerRadius,
|
||||
bottomTrailing: cornerRadius,
|
||||
topTrailing: 0),
|
||||
style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
} else {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
}
|
||||
#else
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
#endif
|
||||
}
|
||||
#else
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
shape
|
||||
.fill(OpenClawChatTheme.composerBackground)
|
||||
.overlay(shape.strokeBorder(OpenClawChatTheme.composerBorder, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
@@ -86,6 +111,30 @@ struct OpenClawChatComposer: View {
|
||||
#endif
|
||||
}
|
||||
|
||||
private var composerToolbar: some View {
|
||||
HStack(spacing: 8) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
if self.showsSessionSwitcher {
|
||||
self.sessionPicker
|
||||
self.thinkingPicker
|
||||
}
|
||||
if self.viewModel.showsModelPicker {
|
||||
self.modelPicker
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if self.style == .standard {
|
||||
self.refreshButton
|
||||
self.attachmentPicker
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
|
||||
private var thinkingPicker: some View {
|
||||
Picker(
|
||||
"Thinking",
|
||||
@@ -145,23 +194,46 @@ struct OpenClawChatComposer: View {
|
||||
@ViewBuilder
|
||||
private var attachmentPicker: some View {
|
||||
#if os(macOS)
|
||||
Button {
|
||||
self.pickFilesMac()
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
if self.composerChrome == .clean {
|
||||
Button {
|
||||
self.pickFilesMac()
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.buttonStyle(.plain)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
self.pickFilesMac()
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.help("Add Image")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
#else
|
||||
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
Task { await self.loadPhotosPickerItems(newItems) }
|
||||
if self.composerChrome == .clean {
|
||||
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.buttonStyle(.plain)
|
||||
.controlSize(.small)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
Task { await self.loadPhotosPickerItems(newItems) }
|
||||
}
|
||||
} else {
|
||||
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
|
||||
Image(systemName: "paperclip")
|
||||
}
|
||||
.help("Add Image")
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.onChange(of: self.pickerItems) { _, newItems in
|
||||
Task { await self.loadPhotosPickerItems(newItems) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -203,18 +275,28 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var editor: some View {
|
||||
if self.composerChrome == .clean {
|
||||
self.cleanEditor
|
||||
} else {
|
||||
self.fullEditor
|
||||
}
|
||||
}
|
||||
|
||||
private var fullEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
self.editorOverlay
|
||||
|
||||
if !self.isComposerCompacted {
|
||||
Rectangle()
|
||||
.fill(OpenClawChatTheme.divider)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
Rectangle()
|
||||
.fill(OpenClawChatTheme.divider)
|
||||
.frame(height: 1)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if let talkControl {
|
||||
self.talkButton(talkControl)
|
||||
}
|
||||
if self.showsConnectionPill {
|
||||
self.connectionPill
|
||||
}
|
||||
@@ -225,29 +307,160 @@ struct OpenClawChatComposer: View {
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(OpenClawChatTheme.composerField)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(OpenClawChatTheme.composerBorder)))
|
||||
.padding(self.editorPadding)
|
||||
}
|
||||
|
||||
private var cleanEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
self.compactAccessory(self.attachmentPicker)
|
||||
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
self.editorOverlay
|
||||
.frame(minHeight: self.cleanControlHeight)
|
||||
|
||||
if let talkControl {
|
||||
self.compactTalkButton(talkControl)
|
||||
}
|
||||
}
|
||||
.padding(.leading, 14)
|
||||
.padding(.trailing, 6)
|
||||
.frame(height: self.cleanControlHeight)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(OpenClawChatTheme.composerField)
|
||||
.overlay(
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(OpenClawChatTheme.composerBorder)))
|
||||
|
||||
self.sendButton
|
||||
.frame(width: self.cleanControlHeight, height: self.cleanControlHeight)
|
||||
}
|
||||
.frame(height: self.cleanControlHeight)
|
||||
|
||||
if self.showsConnectionPill {
|
||||
self.connectionPill
|
||||
.padding(.leading, 52)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func talkButton(_ talkControl: OpenClawChatTalkControl) -> some View {
|
||||
Button {
|
||||
talkControl.toggle(self.viewModel.sessionKey)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: talkControl.isEnabled ? "stop.fill" : "waveform")
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(talkControl.isEnabled ? "Stop" : "Talk")
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(talkControl.isEnabled ? .white : .primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 32)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.talkButtonFill(talkControl))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(self.talkButtonStroke(talkControl), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!talkControl.isGatewayConnected && !talkControl.isEnabled)
|
||||
.accessibilityLabel(talkControl.isEnabled ? "Stop realtime chat" : "Start realtime chat")
|
||||
.accessibilityValue(self.talkAccessibilityValue(talkControl))
|
||||
.help(self.talkHelpText(talkControl))
|
||||
}
|
||||
|
||||
private func compactTalkButton(_ talkControl: OpenClawChatTalkControl) -> some View {
|
||||
Button {
|
||||
talkControl.toggle(self.viewModel.sessionKey)
|
||||
} label: {
|
||||
Image(systemName: talkControl.isEnabled ? "stop.fill" : "waveform")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(talkControl.isEnabled ? .white : .secondary)
|
||||
.frame(width: self.cleanIconControlSize, height: self.cleanIconControlSize)
|
||||
.background {
|
||||
Circle()
|
||||
.fill(self.talkButtonFill(talkControl))
|
||||
}
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(self.talkButtonStroke(talkControl), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!talkControl.isGatewayConnected && !talkControl.isEnabled)
|
||||
.accessibilityLabel(talkControl.isEnabled ? "Stop realtime chat" : "Start realtime chat")
|
||||
.accessibilityValue(self.talkAccessibilityValue(talkControl))
|
||||
.help(self.talkHelpText(talkControl))
|
||||
}
|
||||
|
||||
private func compactAccessory(_ content: some View) -> some View {
|
||||
content
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: self.cleanControlHeight, height: self.cleanControlHeight)
|
||||
}
|
||||
|
||||
private func talkButtonFill(_ talkControl: OpenClawChatTalkControl) -> AnyShapeStyle {
|
||||
if talkControl.isEnabled {
|
||||
return AnyShapeStyle(OpenClawChatTheme.userBubble)
|
||||
}
|
||||
if !talkControl.isGatewayConnected {
|
||||
return AnyShapeStyle(Color.secondary.opacity(0.12))
|
||||
}
|
||||
return OpenClawChatTheme.subtleCard
|
||||
}
|
||||
|
||||
private func talkButtonStroke(_ talkControl: OpenClawChatTalkControl) -> Color {
|
||||
if talkControl.isEnabled {
|
||||
return Color.white.opacity(0.18)
|
||||
}
|
||||
return OpenClawChatTheme.composerBorder
|
||||
}
|
||||
|
||||
private func talkAccessibilityValue(_ talkControl: OpenClawChatTalkControl) -> String {
|
||||
let status = talkControl.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let provider = talkControl.providerLabel.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return [status, provider].filter { !$0.isEmpty }.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private func talkHelpText(_ talkControl: OpenClawChatTalkControl) -> String {
|
||||
if !talkControl.isGatewayConnected, !talkControl.isEnabled {
|
||||
return "Connect the gateway before starting realtime chat"
|
||||
}
|
||||
let action = talkControl.isEnabled ? "Stop" : "Start"
|
||||
return "\(action) realtime chat for \(self.activeSessionLabel)"
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(self.viewModel.healthOK ? .green : .orange)
|
||||
.fill(self.connectionOK ? .green : .orange)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(self.activeSessionLabel)
|
||||
.font(.caption2.weight(.semibold))
|
||||
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
|
||||
Text(self.connectionStatusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(OpenClawChatTheme.subtleCard)
|
||||
.clipShape(Capsule())
|
||||
.padding(.horizontal, self.composerChrome == .clean ? 0 : 8)
|
||||
.padding(.vertical, self.composerChrome == .clean ? 0 : 4)
|
||||
.background {
|
||||
if self.composerChrome == .full {
|
||||
Capsule()
|
||||
.fill(OpenClawChatTheme.subtleCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeSessionLabel: String {
|
||||
@@ -257,12 +470,12 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
|
||||
private var editorOverlay: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ZStack(alignment: self.editorOverlayAlignment) {
|
||||
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Text("Message OpenClaw…")
|
||||
Text(self.placeholderText)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, self.cleanFieldTextInset)
|
||||
.padding(.vertical, self.composerChrome == .clean ? 0 : 4)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
@@ -279,15 +492,23 @@ struct OpenClawChatComposer: View {
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 3)
|
||||
#else
|
||||
TextEditor(text: self.$viewModel.input)
|
||||
TextField(
|
||||
"",
|
||||
text: self.$viewModel.input,
|
||||
axis: .vertical)
|
||||
.font(.system(size: 15))
|
||||
.scrollContentBackground(.hidden)
|
||||
.lineLimit(1...4)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
self.viewModel.send()
|
||||
}
|
||||
.frame(
|
||||
minHeight: self.textMinHeight,
|
||||
idealHeight: self.textMinHeight,
|
||||
maxHeight: self.textMaxHeight)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 4)
|
||||
maxHeight: self.textMaxHeight,
|
||||
alignment: self.editorTextAlignment)
|
||||
.padding(.horizontal, self.cleanFieldTextInset)
|
||||
.padding(.vertical, self.composerChrome == .clean ? 0 : 6)
|
||||
.focused(self.$isFocused)
|
||||
#endif
|
||||
}
|
||||
@@ -295,7 +516,7 @@ struct OpenClawChatComposer: View {
|
||||
|
||||
private var sendButton: some View {
|
||||
Group {
|
||||
if self.viewModel.pendingRunCount > 0 {
|
||||
if self.viewModel.pendingRunCount > 0, !self.viewModel.hasDraftToSend {
|
||||
Button {
|
||||
self.viewModel.abort()
|
||||
} label: {
|
||||
@@ -308,8 +529,12 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
.background(Circle().fill(Color.red))
|
||||
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
|
||||
.fill(Color.red))
|
||||
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
|
||||
.accessibilityLabel("Stop response")
|
||||
.disabled(self.viewModel.isAborting)
|
||||
} else {
|
||||
Button {
|
||||
@@ -324,8 +549,16 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
.background(Circle().fill(Color.accentColor))
|
||||
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
|
||||
.fill(self.viewModel.canSend ? self.sendButtonFill : Color.secondary
|
||||
.opacity(0.32)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(self.viewModel.canSend ? 0.18 : 0.08), lineWidth: 1))
|
||||
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
|
||||
.accessibilityLabel("Send message")
|
||||
.disabled(!self.viewModel.canSend)
|
||||
}
|
||||
}
|
||||
@@ -343,7 +576,7 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
|
||||
private var showsToolbar: Bool {
|
||||
self.style == .standard && !self.isComposerCompacted
|
||||
self.style == .standard && self.composerChrome == .full
|
||||
}
|
||||
|
||||
private var showsAttachments: Bool {
|
||||
@@ -351,31 +584,70 @@ struct OpenClawChatComposer: View {
|
||||
}
|
||||
|
||||
private var showsConnectionPill: Bool {
|
||||
self.style == .standard && !self.isComposerCompacted
|
||||
self.style == .standard && self.composerChrome == .full
|
||||
}
|
||||
|
||||
private var composerPadding: CGFloat {
|
||||
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
|
||||
self.style == .onboarding ? 5 : (self.composerChrome == .clean ? 4 : 6)
|
||||
}
|
||||
|
||||
private var editorPadding: CGFloat {
|
||||
self.style == .onboarding ? 5 : (self.isComposerCompacted ? 4 : 6)
|
||||
self.style == .onboarding ? 5 : (self.composerChrome == .clean ? 4 : 6)
|
||||
}
|
||||
|
||||
private var textMinHeight: CGFloat {
|
||||
self.style == .onboarding ? 24 : 28
|
||||
if self.style == .onboarding { return 24 }
|
||||
return self.composerChrome == .clean ? 24 : 28
|
||||
}
|
||||
|
||||
private var textMaxHeight: CGFloat {
|
||||
self.style == .onboarding ? 52 : 64
|
||||
if self.style == .onboarding { return 52 }
|
||||
return self.composerChrome == .clean ? 48 : 64
|
||||
}
|
||||
|
||||
private var isComposerCompacted: Bool {
|
||||
#if os(macOS)
|
||||
false
|
||||
#else
|
||||
self.style == .standard && self.isFocused
|
||||
#endif
|
||||
private var sendButtonSize: CGFloat {
|
||||
self.composerChrome == .clean ? self.cleanControlHeight : 44
|
||||
}
|
||||
|
||||
private var sendButtonCornerRadius: CGFloat {
|
||||
self.composerChrome == .clean ? self.cleanControlHeight / 2 : 12
|
||||
}
|
||||
|
||||
private var cleanControlHeight: CGFloat {
|
||||
40
|
||||
}
|
||||
|
||||
private var cleanIconControlSize: CGFloat {
|
||||
32
|
||||
}
|
||||
|
||||
private var cleanFieldTextInset: CGFloat {
|
||||
self.composerChrome == .clean ? 0 : 4
|
||||
}
|
||||
|
||||
private var editorOverlayAlignment: Alignment {
|
||||
self.composerChrome == .clean ? .leading : .topLeading
|
||||
}
|
||||
|
||||
private var editorTextAlignment: Alignment {
|
||||
self.composerChrome == .clean ? .leading : .top
|
||||
}
|
||||
|
||||
private var sendButtonFill: Color {
|
||||
self.userAccent ?? OpenClawChatTheme.userBubble
|
||||
}
|
||||
|
||||
private var connectionStatusText: String {
|
||||
self.connectionOK ? "Gateway connected" : "Connecting..."
|
||||
}
|
||||
|
||||
private var connectionOK: Bool {
|
||||
self.viewModel.healthOK || (self.talkControl?.isGatewayConnected ?? false)
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
let trimmed = self.messagePlaceholder?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "Message…" : trimmed
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
@@ -7,6 +7,55 @@ private enum ChatUIConstants {
|
||||
static let bubbleCorner: CGFloat = 18
|
||||
}
|
||||
|
||||
struct ChatAgentAvatar: View {
|
||||
let text: String?
|
||||
let name: String?
|
||||
let tint: Color?
|
||||
var size: CGFloat = 30
|
||||
|
||||
var body: some View {
|
||||
Text(self.displayText)
|
||||
.font(.system(size: self.fontSize, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.minimumScaleFactor(0.6)
|
||||
.lineLimit(1)
|
||||
.frame(width: self.size, height: self.size)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
(self.tint ?? Color.accentColor).opacity(0.95),
|
||||
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(
|
||||
Circle()
|
||||
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: (self.tint ?? Color.accentColor).opacity(0.18), radius: 8, y: 4)
|
||||
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
if let text = self.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
||||
return String(text.prefix(3))
|
||||
}
|
||||
if let name = self.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
|
||||
let words = name.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }).prefix(2)
|
||||
let initials = words.compactMap(\.first).map(String.init).joined()
|
||||
if !initials.isEmpty {
|
||||
return initials.uppercased()
|
||||
}
|
||||
}
|
||||
return "OC"
|
||||
}
|
||||
|
||||
private var fontSize: CGFloat {
|
||||
self.displayText.count > 2 ? self.size * 0.34 : self.size * 0.42
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatBubbleShape: InsettableShape {
|
||||
enum Tail {
|
||||
case left
|
||||
@@ -154,8 +203,40 @@ struct ChatMessageBubble: View {
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
let showsAssistantTrace: Bool
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let showsAssistantAvatar: Bool
|
||||
|
||||
var body: some View {
|
||||
if self.isUser {
|
||||
self.messageBody
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .trailing)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
.padding(.horizontal, 2)
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if self.showsAssistantAvatar {
|
||||
ChatAgentAvatar(
|
||||
text: self.assistantAvatarText,
|
||||
name: self.assistantName,
|
||||
tint: self.assistantAvatarTint)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
self.messageBody
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var isUser: Bool {
|
||||
self.message.role.lowercased() == "user"
|
||||
}
|
||||
|
||||
private var messageBody: some View {
|
||||
ChatMessageBody(
|
||||
message: self.message,
|
||||
isUser: self.isUser,
|
||||
@@ -163,13 +244,6 @@ struct ChatMessageBubble: View {
|
||||
markdownVariant: self.markdownVariant,
|
||||
userAccent: self.userAccent,
|
||||
showsAssistantTrace: self.showsAssistantTrace)
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
|
||||
private var isUser: Bool {
|
||||
self.message.role.lowercased() == "user"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,28 +563,48 @@ private struct ToolResultCard: View {
|
||||
@MainActor
|
||||
struct ChatTypingIndicatorBubble: View {
|
||||
let style: OpenClawChatView.Style
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let showsAssistantAvatar: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
TypingDots()
|
||||
Spacer(minLength: 0)
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if self.showsAssistantAvatar {
|
||||
ChatAgentAvatar(
|
||||
text: self.assistantAvatarText,
|
||||
name: self.assistantName,
|
||||
tint: self.assistantAvatarTint,
|
||||
size: 28)
|
||||
}
|
||||
|
||||
HStack(spacing: 9) {
|
||||
TypingDots()
|
||||
Text("Writing")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, self.style == .standard ? 10 : 9)
|
||||
.padding(.horizontal, self.style == .standard ? 12 : 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
.fill(OpenClawChatTheme.assistantBubble))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 15, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
.padding(.vertical, self.style == .standard ? 12 : 10)
|
||||
.padding(.horizontal, self.style == .standard ? 12 : 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(OpenClawChatTheme.assistantBubble))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.focusable(false)
|
||||
}
|
||||
}
|
||||
|
||||
extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.style == rhs.style
|
||||
lhs.style == rhs.style &&
|
||||
lhs.assistantName == rhs.assistantName &&
|
||||
lhs.assistantAvatarText == rhs.assistantAvatarText &&
|
||||
lhs.showsAssistantAvatar == rhs.showsAssistantAvatar
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,16 +627,31 @@ struct ChatStreamingAssistantBubble: View {
|
||||
let text: String
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let showsAssistantTrace: Bool
|
||||
let assistantName: String?
|
||||
let assistantAvatarText: String?
|
||||
let assistantAvatarTint: Color?
|
||||
let showsAssistantAvatar: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatAssistantTextBody(
|
||||
text: self.text,
|
||||
markdownVariant: self.markdownVariant,
|
||||
includesThinking: self.showsAssistantTrace)
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
if self.showsAssistantAvatar {
|
||||
ChatAgentAvatar(
|
||||
text: self.assistantAvatarText,
|
||||
name: self.assistantName,
|
||||
tint: self.assistantAvatarTint)
|
||||
.padding(.top, 1)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatAssistantTextBody(
|
||||
text: self.text,
|
||||
markdownVariant: self.markdownVariant,
|
||||
includesThinking: self.showsAssistantTrace)
|
||||
}
|
||||
.padding(12)
|
||||
.assistantBubbleContainerStyle()
|
||||
}
|
||||
.padding(12)
|
||||
.assistantBubbleContainerStyle()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -310,6 +310,12 @@ public struct OpenClawChatSendResponse: Codable, Sendable {
|
||||
public let status: String
|
||||
}
|
||||
|
||||
public struct OpenClawChatCreateSessionResponse: Codable, Sendable {
|
||||
public let ok: Bool?
|
||||
public let key: String
|
||||
public let sessionId: String?
|
||||
}
|
||||
|
||||
public struct OpenClawChatEventPayload: Codable, Sendable {
|
||||
public let runId: String?
|
||||
public let sessionKey: String?
|
||||
|
||||
@@ -15,6 +15,33 @@ extension NSAppearance {
|
||||
#endif
|
||||
|
||||
enum OpenClawChatTheme {
|
||||
#if !os(macOS)
|
||||
private enum IOSPalette {
|
||||
static let lightCanvasTop = UIColor(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0, alpha: 1)
|
||||
static let lightCanvasMiddle = UIColor(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0, alpha: 1)
|
||||
static let lightCanvasBottom = UIColor.white
|
||||
static let lightAccent = UIColor(red: 220 / 255.0, green: 38 / 255.0, blue: 38 / 255.0, alpha: 1)
|
||||
static let lightAccentHot = UIColor(red: 239 / 255.0, green: 68 / 255.0, blue: 68 / 255.0, alpha: 1)
|
||||
static let darkCanvasTop = UIColor(red: 12 / 255.0, green: 13 / 255.0, blue: 15 / 255.0, alpha: 1)
|
||||
static let darkCanvasMiddle = UIColor(red: 7 / 255.0, green: 8 / 255.0, blue: 10 / 255.0, alpha: 1)
|
||||
static let darkCanvasBottom = UIColor(red: 4 / 255.0, green: 5 / 255.0, blue: 6 / 255.0, alpha: 1)
|
||||
static let darkPanel = UIColor(red: 10 / 255.0, green: 12 / 255.0, blue: 14 / 255.0, alpha: 1)
|
||||
static let darkPanelRaised = UIColor(red: 17 / 255.0, green: 18 / 255.0, blue: 21 / 255.0, alpha: 1)
|
||||
static let darkComposer = UIColor(red: 24 / 255.0, green: 25 / 255.0, blue: 28 / 255.0, alpha: 1)
|
||||
static let darkAccent = UIColor(red: 198 / 255.0, green: 49 / 255.0, blue: 42 / 255.0, alpha: 1)
|
||||
static let darkAccentHot = UIColor(red: 239 / 255.0, green: 62 / 255.0, blue: 82 / 255.0, alpha: 1)
|
||||
}
|
||||
|
||||
private static func adaptiveColor(
|
||||
light: UIColor,
|
||||
dark: UIColor) -> Color
|
||||
{
|
||||
Color(uiColor: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? dark : light
|
||||
})
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
static func resolvedAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
|
||||
// NSColor semantic colors don't reliably resolve for arbitrary NSAppearance in SwiftPM.
|
||||
@@ -80,7 +107,22 @@ enum OpenClawChatTheme {
|
||||
Color.black.opacity(0.08)
|
||||
}
|
||||
#else
|
||||
Color(uiColor: .systemBackground)
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
self.adaptiveColor(
|
||||
light: IOSPalette.lightCanvasTop,
|
||||
dark: IOSPalette.darkCanvasTop),
|
||||
self.adaptiveColor(
|
||||
light: IOSPalette.lightCanvasMiddle,
|
||||
dark: IOSPalette.darkCanvasMiddle),
|
||||
self.adaptiveColor(
|
||||
light: IOSPalette.lightCanvasBottom,
|
||||
dark: IOSPalette.darkCanvasBottom),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -88,7 +130,7 @@ enum OpenClawChatTheme {
|
||||
#if os(macOS)
|
||||
Color(nsColor: .textBackgroundColor)
|
||||
#else
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
self.adaptiveColor(light: .secondarySystemBackground, dark: IOSPalette.darkPanel)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -96,19 +138,25 @@ enum OpenClawChatTheme {
|
||||
#if os(macOS)
|
||||
AnyShapeStyle(.ultraThinMaterial)
|
||||
#else
|
||||
AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9))
|
||||
AnyShapeStyle(self.adaptiveColor(light: .tertiarySystemBackground, dark: IOSPalette.darkPanelRaised))
|
||||
#endif
|
||||
}
|
||||
|
||||
static var userBubble: Color {
|
||||
#if os(macOS)
|
||||
Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
|
||||
#else
|
||||
self.adaptiveColor(
|
||||
light: IOSPalette.lightAccent,
|
||||
dark: IOSPalette.darkAccent)
|
||||
#endif
|
||||
}
|
||||
|
||||
static var assistantBubble: Color {
|
||||
#if os(macOS)
|
||||
Color(nsColor: self.assistantBubbleDynamicNSColor)
|
||||
#else
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
self.adaptiveColor(light: .secondarySystemBackground, dark: IOSPalette.darkPanelRaised)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -116,7 +164,7 @@ enum OpenClawChatTheme {
|
||||
#if os(macOS)
|
||||
Color(nsColor: self.onboardingAssistantBubbleDynamicNSColor)
|
||||
#else
|
||||
Color(uiColor: .secondarySystemBackground)
|
||||
self.adaptiveColor(light: .secondarySystemBackground, dark: IOSPalette.darkPanelRaised)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -144,7 +192,7 @@ enum OpenClawChatTheme {
|
||||
#if os(macOS)
|
||||
AnyShapeStyle(.ultraThinMaterial)
|
||||
#else
|
||||
AnyShapeStyle(Color(uiColor: .systemBackground))
|
||||
AnyShapeStyle(self.adaptiveColor(light: .secondarySystemGroupedBackground, dark: IOSPalette.darkPanel))
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -152,12 +200,16 @@ enum OpenClawChatTheme {
|
||||
#if os(macOS)
|
||||
AnyShapeStyle(.thinMaterial)
|
||||
#else
|
||||
AnyShapeStyle(Color(uiColor: .secondarySystemBackground))
|
||||
AnyShapeStyle(self.adaptiveColor(light: .secondarySystemBackground, dark: IOSPalette.darkComposer))
|
||||
#endif
|
||||
}
|
||||
|
||||
static var composerBorder: Color {
|
||||
#if os(macOS)
|
||||
Color.white.opacity(0.12)
|
||||
#else
|
||||
self.adaptiveColor(light: .separator, dark: UIColor.white.withAlphaComponent(0.14))
|
||||
#endif
|
||||
}
|
||||
|
||||
static var divider: Color {
|
||||
|
||||
@@ -10,6 +10,11 @@ public enum OpenClawChatTransportEvent: Sendable {
|
||||
}
|
||||
|
||||
public protocol OpenClawChatTransport: Sendable {
|
||||
func createSession(
|
||||
key: String,
|
||||
label: String?,
|
||||
parentSessionKey: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload
|
||||
func listModels() async throws -> [OpenClawChatModelChoice]
|
||||
func sendMessage(
|
||||
@@ -25,6 +30,7 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
func setSessionThinking(sessionKey: String, thinkingLevel: String) async throws
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool
|
||||
func waitForRunCompletion(runId: String, timeoutMs: Int) async -> Bool
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent>
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws
|
||||
@@ -33,8 +39,23 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
}
|
||||
|
||||
extension OpenClawChatTransport {
|
||||
public func createSession(
|
||||
key _: String,
|
||||
label _: String?,
|
||||
parentSessionKey _: String?) async throws -> OpenClawChatCreateSessionResponse
|
||||
{
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "sessions.create not supported by this transport"])
|
||||
}
|
||||
|
||||
public func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
public func waitForRunCompletion(runId _: String, timeoutMs _: Int) async -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
public func resetSession(sessionKey _: String) async throws {
|
||||
throw NSError(
|
||||
domain: "OpenClawChatTransport",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user