mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-15 02:28:52 +08:00
Compare commits
8 Commits
per-versio
...
flat-decla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4149dc0af1 | ||
|
|
39973675c5 | ||
|
|
a974fd2f77 | ||
|
|
59a4ab4daa | ||
|
|
ca61f668d5 | ||
|
|
3cda9be785 | ||
|
|
ed2c6bee69 | ||
|
|
2815ac066b |
@@ -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. 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.
|
||||
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.
|
||||
|
||||
Prefer:
|
||||
|
||||
@@ -142,20 +142,18 @@ No Markdown tables. Compact bullets. Use color/risk markers:
|
||||
Required line shape:
|
||||
|
||||
```markdown
|
||||
- **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.
|
||||
- **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.
|
||||
```
|
||||
|
||||
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, prefer section order: `Best Open Now`, `Useful But Review First`, `Still Open / Not Ready`. Omit merged/closed churn by default.
|
||||
- For refresh-style asks, bottom line: `Merged/closed since last pass: #81016 merged, #81026 closed.` Omit if none.
|
||||
|
||||
@@ -11,10 +11,9 @@ ordering, and audit discipline.
|
||||
|
||||
## Goal
|
||||
|
||||
Rewrite `CHANGELOG.md` as current release notes only. Use history rather than
|
||||
stale draft notes. Produce a per-version file whose dated section more or less
|
||||
matches the shape of prior release sections, sorted by user interest while
|
||||
preserving issue/PR refs and human thanks.
|
||||
Rewrite the target `CHANGELOG.md` version section from history, not from stale
|
||||
draft notes. Produce user-facing release notes sorted by user interest while
|
||||
preserving issue/PR refs and thanks.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -35,13 +34,12 @@ preserving issue/PR refs and human thanks.
|
||||
- also inspect `--since='24 hours ago'` when main moved during the release.
|
||||
3. Read linked PRs/issues or diffs for ambiguous commits. Direct commits matter;
|
||||
infer notes from subject, body, touched files, tests, and nearby commits.
|
||||
4. Rewrite `CHANGELOG.md` as current release notes:
|
||||
- preserve the top `# Changelog` title and docs link
|
||||
- do not keep `## Unreleased`; release notes are regenerated from history
|
||||
4. Rewrite one stable-base section only:
|
||||
- use `## YYYY.M.D`
|
||||
- do not create beta-specific headings
|
||||
- remove older `## YYYY.M.D` sections from the file
|
||||
- keep beta release notes under the stable base heading
|
||||
- do not leave a stale `## Unreleased` section above the target release
|
||||
- if `Unreleased` contains release-bound notes, fold them into the target
|
||||
section instead of deleting them
|
||||
5. Section shape:
|
||||
- `### Highlights`: 5-8 bullets, broad user wins first
|
||||
- `### Changes`: new capabilities and behavior changes
|
||||
|
||||
@@ -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,4 +85,3 @@ 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 public `openclaw/releases` evidence ledger.
|
||||
important run ids in the private `openclaw/releases-private` 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_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.
|
||||
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.
|
||||
|
||||
### Release Checks
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -23,10 +23,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
green. Then branch from that commit so regular development can continue on
|
||||
`main` while release validation runs.
|
||||
- Before release branching, commit any dirty files in coherent groups, push,
|
||||
pull/rebase, then generate the per-version `CHANGELOG.md` on `main` from
|
||||
merged PRs and all direct commits since the last reachable release tag.
|
||||
Commit/push/pull that changelog rewrite immediately before creating the
|
||||
release branch.
|
||||
pull/rebase, then generate `CHANGELOG.md` on `main` from merged PRs and all
|
||||
direct commits since the last reachable release tag. Commit/push/pull that
|
||||
changelog rewrite immediately before creating the release branch.
|
||||
- During release planning, inspect both `src/plugins/compat/registry.ts` and
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` before branching and again
|
||||
before final publish. For every deprecated or removal-pending compatibility
|
||||
@@ -141,10 +140,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
|
||||
- `CHANGELOG.md` is release-owned. Normal PRs and direct `main` fixes should
|
||||
not edit it.
|
||||
- `CHANGELOG.md` contains the current stable-base release section only.
|
||||
- Before release branching or tagging, rewrite `CHANGELOG.md` as current
|
||||
release notes from history, not existing notes. Use the last reachable stable
|
||||
or beta release tag as the base, then inspect every commit through the target
|
||||
- Before release branching or tagging, rewrite the target `CHANGELOG.md`
|
||||
section from history, not existing notes. Use the last reachable stable or
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
@@ -177,10 +175,9 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- use release notes from the stable base `CHANGELOG.md` version section
|
||||
(`## YYYY.M.D`), not a beta-specific heading
|
||||
- attach at least the zip and dSYM zip, plus dmg if available
|
||||
- Keep the current version section in `CHANGELOG.md` sorted by impact:
|
||||
- `### Highlights` first with broad user wins
|
||||
- `### Changes` next for capabilities and behavior changes
|
||||
- `### Fixes` last, deduped with user-facing fixes first
|
||||
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first
|
||||
- `### Fixes` deduped with user-facing fixes first
|
||||
|
||||
## Write release tweets
|
||||
|
||||
@@ -194,7 +191,7 @@ live`; keep it clearly beta and avoid implying stable promotion.
|
||||
- Lead with user-visible capabilities, then important integrations, then
|
||||
reliability/security/install fixes. Compress "lots of fixes" into one
|
||||
readable bullet.
|
||||
- Read the matching changelog section before drafting. Do not lead with coverage,
|
||||
- Read the full changelog section before drafting. Do not lead with coverage,
|
||||
CI, validation, or internal release mechanics unless the release is explicitly
|
||||
about those. Peter prefers concrete user wins: features, integrations,
|
||||
workflow improvements, and practical reliability fixes.
|
||||
@@ -563,9 +560,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
worktree is clean.
|
||||
4. Pull latest `main` and confirm current `main` CI is green.
|
||||
5. Run `/changelog` for the stable base target version on `main`, commit the
|
||||
per-version changelog rewrite immediately, push, and pull/rebase. For beta
|
||||
releases, keep the changelog heading as `## YYYY.M.D`, not
|
||||
`## YYYY.M.D-beta.N`.
|
||||
changelog rewrite immediately, push, and pull/rebase. For beta releases,
|
||||
keep the changelog heading as `## YYYY.M.D`, not `## YYYY.M.D-beta.N`.
|
||||
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
|
||||
7. Make every repo version location match the beta tag before creating it.
|
||||
8. Commit release preparation changes on the release branch and push the branch.
|
||||
|
||||
@@ -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,19 +87,11 @@ When preparing a maintainer-ready close reply:
|
||||
- exact reason for close
|
||||
- exact code refs
|
||||
- exact shipped tag / release facts
|
||||
- fix provenance or canonical duplicate GHSA when applicable
|
||||
- exact fix commit 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:
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -29,11 +29,6 @@ actions:
|
||||
- openclaw
|
||||
runnerVersion: latest
|
||||
ephemeral: true
|
||||
blacksmith:
|
||||
org: openclaw
|
||||
workflow: .github/workflows/ci-check-testbox.yml
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
|
||||
8
.github/CODEOWNERS
vendored
8
.github/CODEOWNERS
vendored
@@ -11,10 +11,8 @@
|
||||
/.github/workflows/codeql.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
@@ -31,7 +29,7 @@
|
||||
/src/gateway/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/security-path*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/openclaw-secops
|
||||
/packages/gateway-protocol/src/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/protocol/**/*secret*.ts @openclaw/openclaw-secops
|
||||
/src/gateway/server-methods/secrets*.ts @openclaw/openclaw-secops
|
||||
/src/agents/*auth*.ts @openclaw/openclaw-secops
|
||||
/src/agents/**/*auth*.ts @openclaw/openclaw-secops
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -11,8 +11,6 @@ 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:
|
||||
|
||||
4
.github/actionlint.yaml
vendored
4
.github/actionlint.yaml
vendored
@@ -14,10 +14,6 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-latest
|
||||
- blacksmith-12vcpu-macos-latest
|
||||
- blacksmith-6vcpu-macos-15
|
||||
- blacksmith-12vcpu-macos-15
|
||||
- blacksmith-6vcpu-macos-26
|
||||
- blacksmith-12vcpu-macos-26
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
|
||||
24
.github/actions/detect-docs-changes/action.yml
vendored
24
.github/actions/detect-docs-changes/action.yml
vendored
@@ -35,29 +35,17 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docs_changed=false
|
||||
non_docs=false
|
||||
while IFS= read -r changed_path; do
|
||||
case "$changed_path" in
|
||||
test/fixtures/*)
|
||||
non_docs=true
|
||||
;;
|
||||
docs/* | *.md | *.mdx)
|
||||
docs_changed=true
|
||||
;;
|
||||
*)
|
||||
non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if [ "$docs_changed" = "true" ]; then
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [ "$non_docs" = "false" ]; then
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
|
||||
10
.github/actions/ensure-base-commit/action.yml
vendored
10
.github/actions/ensure-base-commit/action.yml
vendored
@@ -38,15 +38,9 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
fetch_base_ref() {
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch "$@"
|
||||
}
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
if ! fetch_base_ref --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
if ! git fetch --no-tags --deepen="$deepen_by" origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
@@ -56,7 +50,7 @@ runs:
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
if ! fetch_base_ref --no-tags origin -- "$FETCH_REF"; then
|
||||
if ! git fetch --no-tags origin -- "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
|
||||
14
.github/actions/setup-node-env/action.yml
vendored
14
.github/actions/setup-node-env/action.yml
vendored
@@ -20,13 +20,9 @@ inputs:
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore the pnpm store with actions/cache.
|
||||
description: Whether to restore and save the pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
save-actions-cache:
|
||||
description: Whether to save the pnpm store with actions/cache after install when no exact cache restored.
|
||||
required: false
|
||||
default: "false"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -49,7 +45,6 @@ runs:
|
||||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||||
|
||||
- name: Setup pnpm
|
||||
id: setup-pnpm
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
@@ -135,10 +130,3 @@ runs:
|
||||
ln -sfn "$PNPM_CONFIG_MODULES_DIR" node_modules
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
fi
|
||||
|
||||
- name: Save pnpm store cache
|
||||
if: ${{ inputs.install-deps == 'true' && inputs.use-actions-cache == 'true' && inputs.save-actions-cache == 'true' && runner.os != 'Windows' && steps.setup-pnpm.outputs.store-cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v5
|
||||
with:
|
||||
path: ${{ steps.setup-pnpm.outputs.store-path }}
|
||||
key: ${{ steps.setup-pnpm.outputs.store-cache-primary-key }}
|
||||
|
||||
@@ -14,7 +14,7 @@ inputs:
|
||||
required: false
|
||||
default: ""
|
||||
use-actions-cache:
|
||||
description: Whether actions/cache should restore the pnpm store.
|
||||
description: Whether actions/cache should cache the pnpm store.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
@@ -24,15 +24,6 @@ outputs:
|
||||
project-dir:
|
||||
description: Directory containing the packageManager file used for pnpm resolution.
|
||||
value: ${{ steps.setup-pnpm.outputs.project-dir }}
|
||||
store-cache-hit:
|
||||
description: Whether the pnpm store cache restored an exact key.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-hit }}
|
||||
store-cache-primary-key:
|
||||
description: Exact pnpm store cache key used for restore/save.
|
||||
value: ${{ steps.pnpm-store-cache.outputs.cache-primary-key }}
|
||||
store-path:
|
||||
description: Resolved pnpm store path.
|
||||
value: ${{ steps.pnpm-store.outputs.path }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -90,15 +81,14 @@ runs:
|
||||
echo "path=$store_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
id: pnpm-store-cache
|
||||
if: ${{ inputs.use-actions-cache == 'true' && runner.os != 'Windows' }}
|
||||
uses: actions/cache/restore@v5
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles(inputs.lockfile-path) }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-${{ hashFiles(inputs.package-manager-file) }}-
|
||||
pnpm-store-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-${{ inputs.node-version }}-
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Record pnpm version
|
||||
id: pnpm-version
|
||||
|
||||
@@ -95,7 +95,7 @@ openclaw_find_toolcache_node() {
|
||||
done
|
||||
|
||||
local node_root candidate candidate_version
|
||||
for node_root in ${roots[@]+"${roots[@]}"}; do
|
||||
for node_root in "${roots[@]}"; do
|
||||
while IFS= read -r candidate; do
|
||||
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
|
||||
if openclaw_node_version_matches "$candidate_version" "$requested_node"; then
|
||||
|
||||
@@ -19,7 +19,7 @@ paths:
|
||||
- src/config/types.channel*.ts
|
||||
- src/gateway/server-channel*.ts
|
||||
- src/gateway/server-methods/channels.ts
|
||||
- packages/gateway-protocol/src/schema/channels.ts
|
||||
- src/gateway/protocol/schema/channels.ts
|
||||
- src/infra/channel-*.ts
|
||||
- src/infra/exec-approval-channel-runtime.ts
|
||||
- src/infra/outbound/channel-*.ts
|
||||
|
||||
@@ -30,7 +30,7 @@ paths:
|
||||
- src/gateway/**/*auth*.ts
|
||||
- src/gateway/*secret*.ts
|
||||
- src/gateway/**/*secret*.ts
|
||||
- packages/gateway-protocol/src/**/*secret*.ts
|
||||
- src/gateway/protocol/**/*secret*.ts
|
||||
- src/gateway/resolve-configured-secret-input-string*.ts
|
||||
- src/gateway/security-path*.ts
|
||||
- src/gateway/server-methods/secrets*.ts
|
||||
|
||||
@@ -30,7 +30,7 @@ paths:
|
||||
- src/gateway/**/*auth*.ts
|
||||
- src/gateway/*secret*.ts
|
||||
- src/gateway/**/*secret*.ts
|
||||
- packages/gateway-protocol/src/**/*secret*.ts
|
||||
- src/gateway/protocol/**/*secret*.ts
|
||||
- src/gateway/resolve-configured-secret-input-string*.ts
|
||||
- src/gateway/security-path*.ts
|
||||
- src/gateway/server-methods/secrets*.ts
|
||||
|
||||
@@ -15,7 +15,7 @@ query-filters:
|
||||
|
||||
paths:
|
||||
- src/gateway/method-scopes.ts
|
||||
- packages/gateway-protocol/src
|
||||
- src/gateway/protocol
|
||||
- src/gateway/server-methods
|
||||
- src/gateway/server-methods.ts
|
||||
- src/gateway/server-methods-list.ts
|
||||
|
||||
@@ -9,7 +9,6 @@ queries:
|
||||
paths:
|
||||
- src
|
||||
- extensions
|
||||
- packages/net-policy/src
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -15,6 +15,7 @@ query-filters:
|
||||
|
||||
paths:
|
||||
- src/infra/net
|
||||
- src/shared/net
|
||||
- src/agents/tools/web-fetch.ts
|
||||
- src/agents/tools/web-guarded-fetch.ts
|
||||
- src/agents/tools/web-shared.ts
|
||||
@@ -22,7 +23,6 @@ paths:
|
||||
- src/web-fetch
|
||||
- src/web/provider-runtime-shared.ts
|
||||
- packages/memory-host-sdk/src/host/ssrf-policy.ts
|
||||
- packages/net-policy/src
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
|
||||
@@ -76,8 +76,6 @@ predicate allowedRawSocketClientCall(Expr call) {
|
||||
or
|
||||
allowedOwnerScope(call, "src/proxy-capture/proxy-server.ts", "startDebugProxyServer")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/codex-supervisor/src/json-rpc-client.ts", "connectCodexSupervisorUnixSocket")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/irc/src/client.ts", "connectIrcClient")
|
||||
or
|
||||
allowedOwnerScope(call, "extensions/qa-lab/src/lab-server-capture.ts", "probeTcpReachability")
|
||||
|
||||
20
.github/labeler.yml
vendored
20
.github/labeler.yml
vendored
@@ -47,12 +47,6 @@
|
||||
- "extensions/meeting-notes/**"
|
||||
- "docs/plugins/meeting-notes.md"
|
||||
- "src/meeting-notes/**"
|
||||
"plugin: workboard":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/workboard/**"
|
||||
- "docs/plugins/workboard.md"
|
||||
- "docs/plugins/reference/workboard.md"
|
||||
"plugin: migrate-hermes":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -194,7 +188,7 @@
|
||||
- "ui/**"
|
||||
- "src/gateway/control-ui.ts"
|
||||
- "src/gateway/control-ui-shared.ts"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/gateway/protocol/**"
|
||||
- "src/gateway/server-methods/chat.ts"
|
||||
- "src/infra/control-ui-assets.ts"
|
||||
|
||||
@@ -202,7 +196,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/gateway/**"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/daemon/**"
|
||||
- "docs/gateway/**"
|
||||
|
||||
@@ -405,17 +398,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/codex/**"
|
||||
"extensions: codex-supervisor":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/codex-supervisor/**"
|
||||
- "docs/plugins/reference/codex-supervisor.md"
|
||||
- "docs/specs/claw-supervisor.md"
|
||||
"extensions: copilot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/copilot/**"
|
||||
- "docs/plugins/copilot.md"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -188,10 +188,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
|
||||
5
.github/workflows/ci-check-testbox.yml
vendored
5
.github/workflows/ci-check-testbox.yml
vendored
@@ -89,10 +89,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
|
||||
354
.github/workflows/ci.yml
vendored
354
.github/workflows/ci.yml
vendored
@@ -86,38 +86,12 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
||||
:
|
||||
else
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
||||
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
||||
exit "$fetch_status"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
||||
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
|
||||
fi
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
@@ -347,38 +321,12 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
||||
:
|
||||
else
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
||||
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
||||
exit "$fetch_status"
|
||||
fi
|
||||
if ! git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_REF}:refs/remotes/origin/checkout"; then
|
||||
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
||||
exit "$fetch_status"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
||||
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_FALLBACK_REF}:refs/remotes/origin/checkout"
|
||||
fi
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
@@ -466,73 +414,13 @@ jobs:
|
||||
- name: Audit production dependencies
|
||||
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
||||
|
||||
# Warm the lockfile- and pnpm-pinned store once before Linux Node shards fan out.
|
||||
# On a cold key this job owns the save, so later shards restore the exact key.
|
||||
pnpm-store-warmup:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
save-actions-cache: "true"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
||||
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -602,6 +490,9 @@ 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
|
||||
@@ -694,20 +585,6 @@ 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 \
|
||||
@@ -722,6 +599,10 @@ 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]}"
|
||||
@@ -764,7 +645,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -853,7 +734,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -933,7 +814,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.checkName }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
@@ -1085,9 +966,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1191,9 +1072,9 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1284,7 +1165,7 @@ jobs:
|
||||
pnpm lint:auth:pairing-account-scope
|
||||
pnpm check:import-cycles
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
@@ -1322,8 +1203,8 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
@@ -1489,7 +1370,7 @@ jobs:
|
||||
check-docs:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
@@ -1546,44 +1427,11 @@ jobs:
|
||||
- name: Checkout ClawHub docs source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE/clawhub-source"
|
||||
started_at="$(date +%s)"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach refs/remotes/origin/checkout || return 1
|
||||
echo "ClawHub checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
elapsed="$(( $(date +%s) - started_at ))"
|
||||
echo "ClawHub checkout completed in ${elapsed}s"
|
||||
exit 0
|
||||
fi
|
||||
echo "ClawHub checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "ClawHub checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
git init clawhub-source
|
||||
git -C clawhub-source config gc.auto 0
|
||||
git -C clawhub-source remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
git -C clawhub-source fetch --no-tags --depth=1 origin "+HEAD:refs/remotes/origin/checkout"
|
||||
git -C clawhub-source checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
@@ -1607,25 +1455,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
@@ -1673,27 +1503,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge 30 ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
@@ -1778,7 +1588,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-15' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-15' || 'macos-15') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1793,27 +1603,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge 30 ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -1847,7 +1637,7 @@ jobs:
|
||||
name: "macos-swift"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1859,27 +1649,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge 30 ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
@@ -2090,53 +1860,3 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ci-timings-summary:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
name: ci-timings-summary
|
||||
needs:
|
||||
- preflight
|
||||
- security-fast
|
||||
- pnpm-store-warmup
|
||||
- build-artifacts
|
||||
- checks-fast-core
|
||||
- checks-fast-plugin-contracts-shard
|
||||
- checks-fast-channel-contracts-shard
|
||||
- checks-node-compat
|
||||
- checks-node-core-test-nondist-shard
|
||||
- check-shard
|
||||
- check-additional-shard
|
||||
- check-docs
|
||||
- skills-python
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout timing summary helper
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || needs.preflight.outputs.checkout_revision || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Write CI timing summary
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/ci-run-timings.mjs "$GITHUB_RUN_ID" --limit 25 > ci-timings-summary.txt
|
||||
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload CI timing summary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ci-timings-summary
|
||||
path: ci-timings-summary.txt
|
||||
retention-days: 14
|
||||
|
||||
9
.github/workflows/clawsweeper-dispatch.yml
vendored
9
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -24,14 +24,7 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'issue_comment' ||
|
||||
!(
|
||||
endsWith(github.actor, '[bot]') &&
|
||||
(github.event.action == 'labeled' || github.event.action == 'unlabeled')
|
||||
)
|
||||
}}
|
||||
if: ${{ github.event_name == 'issue_comment' || !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
|
||||
env:
|
||||
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
|
||||
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
|
||||
|
||||
11
.github/workflows/codeql-critical-quality.yml
vendored
11
.github/workflows/codeql-critical-quality.yml
vendored
@@ -33,7 +33,6 @@ on:
|
||||
- "packages/plugin-package-contract/**"
|
||||
- "packages/plugin-sdk/**"
|
||||
- "packages/memory-host-sdk/**"
|
||||
- "packages/net-policy/**"
|
||||
- "src/*.ts"
|
||||
- "src/**/*.ts"
|
||||
- "src/config/**"
|
||||
@@ -107,13 +106,13 @@ on:
|
||||
- "src/gateway/**/*auth*.ts"
|
||||
- "src/gateway/*secret*.ts"
|
||||
- "src/gateway/**/*secret*.ts"
|
||||
- "packages/gateway-protocol/src/**/*secret*.ts"
|
||||
- "src/gateway/protocol/**/*secret*.ts"
|
||||
- "src/gateway/resolve-configured-secret-input-string*.ts"
|
||||
- "src/gateway/security-path*.ts"
|
||||
- "src/gateway/server-methods/secrets*.ts"
|
||||
- "src/gateway/server-startup-memory.ts"
|
||||
- "src/gateway/method-scopes.ts"
|
||||
- "packages/gateway-protocol/src/**"
|
||||
- "src/gateway/protocol/**"
|
||||
- "src/gateway/server-methods/**"
|
||||
- "src/gateway/server-methods.ts"
|
||||
- "src/gateway/server-methods-list.ts"
|
||||
@@ -245,14 +244,14 @@ jobs:
|
||||
src/config/*)
|
||||
config=true
|
||||
;;
|
||||
packages/gateway-protocol/src/*secret*.ts|packages/gateway-protocol/src/**/*secret*.ts|src/gateway/server-methods/secrets*.ts)
|
||||
src/gateway/protocol/*secret*.ts|src/gateway/server-methods/secrets*.ts)
|
||||
core_auth_secrets=true
|
||||
gateway=true
|
||||
;;
|
||||
src/agents/*auth*.ts|src/agents/auth-health*.ts|src/agents/auth-profiles|src/agents/auth-profiles/*|src/agents/bash-tools.exec-host-shared.ts|src/agents/sandbox|src/agents/sandbox.ts|src/agents/sandbox-*.ts|src/agents/sandbox/*|src/cron/service/jobs.ts|src/cron/stagger.ts|src/gateway/*auth*.ts|src/gateway/*secret*.ts|src/gateway/resolve-configured-secret-input-string*.ts|src/gateway/security-path*.ts|src/infra/secret-file*.ts|src/secrets/*|src/security/*)
|
||||
core_auth_secrets=true
|
||||
;;
|
||||
packages/gateway-protocol/src/*|packages/gateway-protocol/src/**/*|src/gateway/method-scopes.ts|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
src/gateway/method-scopes.ts|src/gateway/protocol/*|src/gateway/server-methods/*|src/gateway/server-methods.ts|src/gateway/server-methods-list.ts)
|
||||
gateway=true
|
||||
;;
|
||||
packages/memory-host-sdk/*|src/commands/doctor-cron-dreaming-payload-migration.ts|src/commands/doctor-memory-search.ts|src/gateway/server-startup-memory.ts|src/memory/*|src/memory-host-sdk/*)
|
||||
@@ -302,7 +301,7 @@ jobs:
|
||||
esac
|
||||
|
||||
case "${file}" in
|
||||
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
|
||||
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts)
|
||||
network_runtime=true
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -20,7 +20,7 @@ permissions:
|
||||
jobs:
|
||||
macos:
|
||||
name: Critical Security (macOS)
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
20
.github/workflows/codeql.yml
vendored
20
.github/workflows/codeql.yml
vendored
@@ -19,15 +19,6 @@ on:
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -85,21 +76,10 @@ jobs:
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ matrix.category != 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout Actions security sources
|
||||
if: ${{ matrix.category == 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
.github/workflows
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
|
||||
31
.github/workflows/crabbox-hydrate.yml
vendored
31
.github/workflows/crabbox-hydrate.yml
vendored
@@ -137,10 +137,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
fi
|
||||
|
||||
- name: Prepare Crabbox shell
|
||||
@@ -321,26 +318,7 @@ jobs:
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (git rev-parse --is-inside-work-tree 2>$null) {
|
||||
$repo = (Get-Location).Path
|
||||
$fetchInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$fetchInfo.FileName = "git"
|
||||
$fetchInfo.WorkingDirectory = $repo
|
||||
$fetchInfo.UseShellExecute = $false
|
||||
$fetchInfo.Arguments = '-c protocol.version=2 fetch --no-tags --no-progress --prune --no-recurse-submodules --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"'
|
||||
|
||||
$fetch = New-Object System.Diagnostics.Process
|
||||
$fetch.StartInfo = $fetchInfo
|
||||
if (-not $fetch.Start()) {
|
||||
throw "git fetch failed to start"
|
||||
}
|
||||
if (-not $fetch.WaitForExit(30000)) {
|
||||
$fetch.Kill()
|
||||
$fetch.WaitForExit()
|
||||
throw "git fetch timed out after 30 seconds"
|
||||
}
|
||||
if ($fetch.ExitCode -ne 0) {
|
||||
throw "git fetch failed with exit code $($fetch.ExitCode)"
|
||||
}
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
}
|
||||
|
||||
- name: Setup pnpm and dependencies
|
||||
@@ -535,10 +513,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
|
||||
176
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
176
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
name: Dependency Change Awareness
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-change-awareness-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-change-awareness:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Label and comment on dependency changes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const marker = "<!-- openclaw:dependency-change-awareness -->";
|
||||
const labelName = "dependencies-changed";
|
||||
const maxListedFiles = 25;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
|
||||
if (!pullRequest) {
|
||||
core.info("No pull_request payload found; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isDependencyFile = (filename) =>
|
||||
filename === "package.json" ||
|
||||
filename === "package-lock.json" ||
|
||||
filename === "npm-shrinkwrap.json" ||
|
||||
filename === "pnpm-lock.yaml" ||
|
||||
filename === "pnpm-workspace.yaml" ||
|
||||
filename === "ui/package.json" ||
|
||||
filename.startsWith("patches/") ||
|
||||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package-lock\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/npm-shrinkwrap\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
|
||||
|
||||
const sanitizeDisplayValue = (value) =>
|
||||
String(value)
|
||||
.replace(/[\u0000-\u001f\u007f]/gu, "?")
|
||||
.slice(0, 240);
|
||||
const markdownCode = (value) =>
|
||||
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
|
||||
const ignoreUnavailableWritePermission = (action) => (error) => {
|
||||
if (error?.status === 403) {
|
||||
core.warning(
|
||||
`Skipping dependency change ${action}; token does not have issue write permission.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error?.status === 404 || error?.status === 422) {
|
||||
core.warning(`Dependency change ${action} is unavailable.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const dependencyFiles = files
|
||||
.map((file) => file.filename)
|
||||
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existingComment = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (dependencyFiles.length === 0) {
|
||||
if (hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: labelName,
|
||||
}).catch(ignoreUnavailableWritePermission("label removal"));
|
||||
}
|
||||
if (existingComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
}).catch(ignoreUnavailableWritePermission("comment deletion"));
|
||||
}
|
||||
await core.summary
|
||||
.addHeading("Dependency Change Awareness")
|
||||
.addRaw("No dependency-related file changes detected.")
|
||||
.write();
|
||||
core.info("No dependency-related file changes detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [labelName],
|
||||
}).catch(ignoreUnavailableWritePermission(`label "${labelName}" update`));
|
||||
}
|
||||
|
||||
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
|
||||
const omittedCount = dependencyFiles.length - listedFiles.length;
|
||||
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
|
||||
if (omittedCount > 0) {
|
||||
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
"",
|
||||
"### Dependency Changes Detected",
|
||||
"",
|
||||
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
|
||||
"",
|
||||
"Changed files:",
|
||||
...fileLines,
|
||||
"",
|
||||
"Maintainer follow-up:",
|
||||
"- Review whether the dependency changes are intentional.",
|
||||
"- Inspect resolved package deltas when lockfile, shrinkwrap, or workspace dependency policy changes are present.",
|
||||
"- Treat `package-lock.json` and `npm-shrinkwrap.json` diffs as security-review surfaces.",
|
||||
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
|
||||
].join("\n");
|
||||
|
||||
if (existingComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
body,
|
||||
}).catch(ignoreUnavailableWritePermission("comment update"));
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body,
|
||||
}).catch(ignoreUnavailableWritePermission("comment creation"));
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Dependency Change Awareness")
|
||||
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
|
||||
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
|
||||
.write();
|
||||
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);
|
||||
33
.github/workflows/dependency-guard.yml
vendored
33
.github/workflows/dependency-guard.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Dependency Guard
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-guard:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Label, comment, and guard dependency changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
193
.github/workflows/docker-release.yml
vendored
193
.github/workflows/docker-release.yml
vendored
@@ -75,7 +75,6 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
browser_digest: ${{ steps.build-browser.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -103,18 +102,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-amd64")
|
||||
slim_tags+=("${IMAGE}:main-slim-amd64")
|
||||
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}"
|
||||
@@ -124,9 +119,6 @@ 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)
|
||||
@@ -170,27 +162,6 @@ 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:
|
||||
@@ -235,26 +206,6 @@ 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]
|
||||
@@ -266,7 +217,6 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
browser_digest: ${{ steps.build-browser.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -294,18 +244,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main-arm64")
|
||||
slim_tags+=("${IMAGE}:main-slim-arm64")
|
||||
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}"
|
||||
@@ -315,9 +261,6 @@ 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)
|
||||
@@ -361,27 +304,6 @@ 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:
|
||||
@@ -426,26 +348,6 @@ 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]
|
||||
@@ -480,25 +382,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
tags=()
|
||||
slim_tags=()
|
||||
browser_tags=()
|
||||
browser_supported=0
|
||||
if grep -q '^ARG OPENCLAW_INSTALL_BROWSER' Dockerfile; then
|
||||
browser_supported=1
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
tags+=("${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:main-slim")
|
||||
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" "${IMAGE}:main")
|
||||
slim_tags+=("${IMAGE}:slim" "${IMAGE}:main-slim")
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
browser_tags+=("${IMAGE}:latest-browser" "${IMAGE}:main-browser")
|
||||
fi
|
||||
tags+=("${IMAGE}:latest")
|
||||
slim_tags+=("${IMAGE}:slim")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#tags[@]} -eq 0 ]]; then
|
||||
@@ -509,39 +404,25 @@ 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}"
|
||||
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
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_DIGEST}" \
|
||||
"${ARM64_DIGEST}"
|
||||
|
||||
verify-attestations:
|
||||
needs: [create-manifest]
|
||||
@@ -579,39 +460,21 @@ jobs:
|
||||
slim_multi_refs=()
|
||||
amd64_refs=()
|
||||
arm64_refs=()
|
||||
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
|
||||
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")
|
||||
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"
|
||||
)
|
||||
if [[ "${browser_supported}" == "1" ]]; then
|
||||
multi_refs+=("${IMAGE}:${version}-browser")
|
||||
amd64_refs+=("${IMAGE}:${version}-browser-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-browser-arm64")
|
||||
fi
|
||||
amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64")
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
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
|
||||
multi_refs+=("${IMAGE}:latest")
|
||||
slim_multi_refs+=("${IMAGE}:slim")
|
||||
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 release evidence report
|
||||
description: Optional published package spec to prove in the private release evidence report
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -1450,9 +1450,9 @@ jobs:
|
||||
|
||||
exit "$failed"
|
||||
|
||||
- name: Request release evidence update
|
||||
- name: Request private evidence update
|
||||
env:
|
||||
RELEASES_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_DISPATCH_TOKEN }}
|
||||
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_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 release evidence update."
|
||||
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
|
||||
exit 0
|
||||
fi
|
||||
if [[ -z "${RELEASES_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_DISPATCH_TOKEN is not configured; skipping automatic release evidence update."
|
||||
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
|
||||
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private 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 release evidence update."
|
||||
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private 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 ${RELEASES_DISPATCH_TOKEN}" \
|
||||
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/openclaw/releases/dispatches \
|
||||
https://api.github.com/repos/openclaw/releases-private/dispatches \
|
||||
-d "$payload"; then
|
||||
echo "::warning::Automatic release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
|
||||
{
|
||||
echo "### Release evidence dispatch failed"
|
||||
echo "### Private release evidence dispatch failed"
|
||||
echo
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases\`:"
|
||||
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
|
||||
echo
|
||||
echo "\`\`\`bash"
|
||||
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 "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 "\`\`\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
|
||||
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/.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."
|
||||
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."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -48,8 +48,7 @@ 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
|
||||
CRABBOX_CAPACITY_REGIONS: eu-west-1,eu-west-2,eu-central-1,us-east-1,us-west-2
|
||||
MANTIS_OUTPUT_DIR: .artifacts/qa-e2e/mantis/telegram-desktop-proof
|
||||
|
||||
jobs:
|
||||
@@ -225,7 +224,6 @@ jobs:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -241,6 +239,9 @@ 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"
|
||||
@@ -254,6 +255,7 @@ 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
|
||||
@@ -267,11 +269,6 @@ 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
|
||||
@@ -426,7 +423,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_AWS_REGION CRABBOX_CAPACITY_REGIONS CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
|
||||
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 += "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"'
|
||||
@@ -455,7 +452,6 @@ 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 }}
|
||||
|
||||
5
.github/workflows/mantis-telegram-live.yml
vendored
5
.github/workflows/mantis-telegram-live.yml
vendored
@@ -44,8 +44,6 @@ 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:
|
||||
@@ -377,7 +375,6 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
|
||||
@@ -386,8 +383,6 @@ 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 }}
|
||||
|
||||
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -218,7 +218,6 @@ jobs:
|
||||
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ inputs.scenario }}
|
||||
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline
|
||||
run: |
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
timeout --preserve-status 300s npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
|
||||
- name: Capture candidate metadata
|
||||
id: candidate_metadata
|
||||
|
||||
@@ -480,35 +480,6 @@ jobs:
|
||||
fi
|
||||
exit 1
|
||||
|
||||
plan_release_workflow_matrices:
|
||||
needs: validate_selected_ref
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
docker_e2e_count: ${{ steps.plan.outputs.docker_e2e_count }}
|
||||
docker_e2e_matrix: ${{ steps.plan.outputs.docker_e2e_matrix }}
|
||||
docker_e2e_omitted_json: ${{ steps.plan.outputs.docker_e2e_omitted_json }}
|
||||
live_models_count: ${{ steps.plan.outputs.live_models_count }}
|
||||
live_models_matrix: ${{ steps.plan.outputs.live_models_matrix }}
|
||||
live_models_omitted_json: ${{ steps.plan.outputs.live_models_omitted_json }}
|
||||
steps:
|
||||
- name: Checkout trusted release harness
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Plan release workflow matrices
|
||||
id: plan
|
||||
env:
|
||||
DOCKER_LANES: ${{ inputs.docker_lanes }}
|
||||
INCLUDE_LIVE_SUITES: ${{ inputs.include_live_suites }}
|
||||
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include_release_path_suites }}
|
||||
LIVE_MODEL_PROVIDERS: ${{ inputs.live_model_providers }}
|
||||
LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_TEST_PROFILE: ${{ inputs.release_test_profile }}
|
||||
run: node scripts/plan-release-workflow-matrix.mjs >> "$GITHUB_OUTPUT"
|
||||
|
||||
validate_release_live_cache:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
|
||||
@@ -665,15 +636,72 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == '' && needs.plan_release_workflow_matrices.outputs.docker_e2e_count != '0'
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.docker_e2e_matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- chunk_id: core
|
||||
label: core
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: package-update-openai
|
||||
label: package/update OpenAI install
|
||||
timeout_minutes: 45
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-anthropic
|
||||
label: package/update Anthropic install
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: package-update-core
|
||||
label: package/update core
|
||||
timeout_minutes: 60
|
||||
profiles: beta minimum stable full
|
||||
- chunk_id: plugins-runtime-plugins
|
||||
label: plugins/runtime plugins
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-services
|
||||
label: plugins/runtime services
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-a
|
||||
label: plugins/runtime install A
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-b
|
||||
label: plugins/runtime install B
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-c
|
||||
label: plugins/runtime install C
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-d
|
||||
label: plugins/runtime install D
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-e
|
||||
label: plugins/runtime install E
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-f
|
||||
label: plugins/runtime install F
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-g
|
||||
label: plugins/runtime install G
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
- chunk_id: plugins-runtime-install-h
|
||||
label: plugins/runtime install H
|
||||
timeout_minutes: 60
|
||||
profiles: stable full
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1603,14 +1631,42 @@ jobs:
|
||||
|
||||
validate_live_models_docker:
|
||||
name: Docker live models (${{ matrix.provider_label }})
|
||||
needs: [validate_selected_ref, prepare_live_test_image, plan_release_workflow_matrices]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models') && needs.plan_release_workflow_matrices.outputs.live_models_count != '0'
|
||||
needs: [validate_selected_ref, prepare_live_test_image]
|
||||
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
|
||||
continue-on-error: ${{ inputs.advisory }}
|
||||
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.plan_release_workflow_matrices.outputs.live_models_matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- provider_label: Anthropic
|
||||
providers: anthropic
|
||||
profiles: stable full
|
||||
- provider_label: Google
|
||||
providers: google
|
||||
profiles: stable full
|
||||
- provider_label: MiniMax
|
||||
providers: minimax
|
||||
profiles: stable full
|
||||
- provider_label: OpenAI
|
||||
providers: openai
|
||||
profiles: beta minimum stable full
|
||||
- provider_label: OpenCode
|
||||
providers: opencode-go
|
||||
profiles: full
|
||||
- provider_label: OpenRouter
|
||||
providers: openrouter
|
||||
profiles: full
|
||||
- provider_label: xAI
|
||||
providers: xai
|
||||
profiles: full
|
||||
- provider_label: Z.ai
|
||||
providers: zai
|
||||
profiles: full
|
||||
- provider_label: Fireworks
|
||||
providers: fireworks
|
||||
profiles: full
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -1688,8 +1744,6 @@ jobs:
|
||||
- name: Validate provider credential
|
||||
if: contains(matrix.profiles, inputs.release_test_profile)
|
||||
shell: bash
|
||||
env:
|
||||
LIVE_MODEL_PROVIDERS: ${{ matrix.providers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -1706,7 +1760,7 @@ jobs:
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
case "${{ matrix.providers }}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
@@ -1717,7 +1771,7 @@ jobs:
|
||||
zai) require_any Z.ai ZAI_API_KEY Z_AI_API_KEY ;;
|
||||
fireworks) require_any Fireworks FIREWORKS_API_KEY ;;
|
||||
*)
|
||||
echo "Unhandled live model provider shard: ${LIVE_MODEL_PROVIDERS}" >&2
|
||||
echo "Unhandled live model provider shard: ${{ matrix.providers }}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1932,7 +1986,7 @@ jobs:
|
||||
- suite_id: native-live-src-gateway-profiles-anthropic-opus
|
||||
suite_group: native-live-src-gateway-profiles-anthropic
|
||||
label: Native live gateway profiles Anthropic Opus
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-8 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 30
|
||||
profile_env_only: false
|
||||
advisory: true
|
||||
@@ -1947,19 +2001,19 @@ jobs:
|
||||
profiles: full
|
||||
- suite_id: native-live-src-gateway-profiles-google
|
||||
label: Native live gateway profiles Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-minimax
|
||||
label: Native live gateway profiles MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-openai
|
||||
label: Native live gateway profiles OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=180000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2234,7 +2288,7 @@ jobs:
|
||||
include:
|
||||
- suite_id: live-gateway-docker
|
||||
label: Docker live gateway OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_THINKING=low OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
@@ -2246,13 +2300,13 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-google-docker
|
||||
label: Docker live gateway Google
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=google OPENCLAW_LIVE_GATEWAY_MODELS=google/gemini-3.1-pro-preview,google/gemini-3-flash-preview OPENCLAW_LIVE_GATEWAY_MAX_MODELS=2 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
- suite_id: live-gateway-minimax-docker
|
||||
label: Docker live gateway MiniMax
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MODELS=minimax/MiniMax-M2.7,minimax-portal/MiniMax-M2.7 OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=minimax,minimax-portal OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=90000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=180000 OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-gateway-models-docker.sh
|
||||
timeout_minutes: 40
|
||||
profile_env_only: false
|
||||
profiles: stable full
|
||||
|
||||
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/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this source workflow can stay focused on OIDC publish only.
|
||||
# openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml
|
||||
# so this public 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,31 +551,25 @@ 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 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
|
||||
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
|
||||
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' && steps.clawgrit_reports.outputs.ready == 'true' }}
|
||||
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
|
||||
env:
|
||||
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
|
||||
shell: bash
|
||||
@@ -648,9 +642,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
sleep $((attempt * 2))
|
||||
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" fetch --depth=1 origin main
|
||||
git -C "$reports_root" rebase FETCH_HEAD
|
||||
done
|
||||
|
||||
@@ -813,7 +813,7 @@ jobs:
|
||||
alt_model="openai/gpt-5.5-alt"
|
||||
;;
|
||||
baseline)
|
||||
model="anthropic/claude-opus-4-8"
|
||||
model="anthropic/claude-opus-4-7"
|
||||
alt_model="anthropic/claude-sonnet-4-6"
|
||||
;;
|
||||
*)
|
||||
@@ -885,7 +885,7 @@ jobs:
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-8 \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
@@ -1207,7 +1207,6 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
run: |
|
||||
|
||||
@@ -122,10 +122,6 @@ jobs:
|
||||
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${PLUGIN_PUBLISH_SCOPE}" != "all-publishable" ]]; then
|
||||
echo "publish_openclaw_npm=true requires plugin_publish_scope=all-publishable so every publishable official plugin is released with OpenClaw." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
|
||||
echo "plugin_publish_scope=selected requires plugins." >&2
|
||||
exit 1
|
||||
@@ -814,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/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- full release CI report: https://github.com/openclaw/releases-private/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}`,
|
||||
|
||||
6
.github/workflows/opengrep-precise-full.yml
vendored
6
.github/workflows/opengrep-precise-full.yml
vendored
@@ -32,11 +32,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
8
.github/workflows/opengrep-precise.yml
vendored
8
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
8
.github/workflows/plugin-clawhub-release.yml
vendored
8
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -431,8 +431,7 @@ jobs:
|
||||
EOF
|
||||
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check ClawHub package version
|
||||
id: clawhub_package_version
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
@@ -457,17 +456,14 @@ jobs:
|
||||
done
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
if: steps.clawhub_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
|
||||
8
.github/workflows/plugin-npm-release.yml
vendored
8
.github/workflows/plugin-npm-release.yml
vendored
@@ -263,8 +263,7 @@ jobs:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check npm package version
|
||||
id: npm_package_version
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
@@ -272,13 +271,10 @@ jobs:
|
||||
set -euo pipefail
|
||||
if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.npm_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
22
.github/workflows/qa-live-transports-convex.yml
vendored
22
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -52,7 +52,6 @@ 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"
|
||||
|
||||
@@ -199,13 +198,13 @@ jobs:
|
||||
--alt-model openai/gpt-5.5-alt \
|
||||
--output-dir .artifacts/qa-e2e/openai-candidate
|
||||
|
||||
- name: Run Opus 4.8 lane
|
||||
- name: Run Opus 4.7 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model anthropic/claude-opus-4-8 \
|
||||
--model anthropic/claude-opus-4-7 \
|
||||
--alt-model anthropic/claude-sonnet-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/anthropic-baseline
|
||||
|
||||
@@ -216,7 +215,7 @@ jobs:
|
||||
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--baseline-label anthropic/claude-opus-4-8 \
|
||||
--baseline-label anthropic/claude-opus-4-7 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
- name: Upload parity artifacts
|
||||
@@ -289,7 +288,7 @@ jobs:
|
||||
--runtime-parity-tier live-only \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--runtime-pair openclaw,codex \
|
||||
--fast \
|
||||
--allow-failures \
|
||||
@@ -374,7 +373,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--profile "${INPUT_MATRIX_PROFILE}" \
|
||||
--fast
|
||||
)
|
||||
@@ -458,7 +457,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--profile "${{ matrix.profile }}" \
|
||||
--fast
|
||||
)
|
||||
@@ -530,7 +529,6 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
|
||||
@@ -557,7 +555,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -651,7 +649,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.5 \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model openai/gpt-5.5 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -748,7 +746,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
@@ -842,7 +840,7 @@ jobs:
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_FALLBACK_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
|
||||
2
.github/workflows/website-installer-sync.yml
vendored
2
.github/workflows/website-installer-sync.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
bash -lc 'apt-get update -y && apt-get install -y curl && bash /tmp/install-cli.sh --prefix /tmp/openclaw --no-onboard --version latest && /tmp/openclaw/bin/openclaw --version'
|
||||
|
||||
macos-installer:
|
||||
runs-on: macos-15
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
15
.github/workflows/workflow-sanity.yml
vendored
15
.github/workflows/workflow-sanity.yml
vendored
@@ -34,10 +34,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
@@ -78,10 +75,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Install actionlint
|
||||
@@ -122,10 +116,7 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" fetch --no-tags --depth=1 origin "+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -178,7 +178,6 @@ mantis/
|
||||
/local/
|
||||
/client_secret_*.json
|
||||
package-lock.json
|
||||
!src/commands/copilot-sdk-install-manifest/package-lock.json
|
||||
.claude/
|
||||
.agent/
|
||||
skills-lock.json
|
||||
@@ -250,7 +249,6 @@ 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)
|
||||
|
||||
@@ -186,7 +186,7 @@
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml",
|
||||
"skills/**",
|
||||
"skills/",
|
||||
"src/auto-reply/reply/export-html/template.js",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"vendor/",
|
||||
|
||||
@@ -35,9 +35,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## Map
|
||||
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `packages/gateway-protocol/*`; docs/apps: `docs/`, `apps/`.
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `src/gateway/protocol/*`; docs/apps: `docs/`, `apps/`.
|
||||
- Installers: sibling `../openclaw.ai`.
|
||||
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,agents}/`, `packages/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
- Scoped guides: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,gateway/protocol,agents}/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -57,7 +57,6 @@ 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.
|
||||
|
||||
12037
CHANGELOG.md
12037
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -48,7 +48,6 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
|
||||
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
|
||||
# Copy pinned Bun binary from the official image instead of fetching via curl.
|
||||
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
|
||||
@@ -78,12 +77,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
# still exiting successfully, so retry the package downloader before failing.
|
||||
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
|
||||
RUN set -eux; \
|
||||
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
|
||||
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo "==> Verifying critical native addons..."; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q .; then \
|
||||
|
||||
146
appcast.xml
146
appcast.xml
@@ -2,53 +2,6 @@
|
||||
<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>
|
||||
@@ -537,5 +490,104 @@
|
||||
]]></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 isLocalCleartextGatewayHost(
|
||||
internal fun isPrivateLanGatewayHost(
|
||||
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 (isLocalCleartextGatewayHost(endpoint.host)) return true
|
||||
if (isLoopbackGatewayHost(endpoint.host)) return true
|
||||
return tls != null
|
||||
}
|
||||
|
||||
@@ -1212,7 +1212,9 @@ class GatewaySession(
|
||||
endpoint: GatewayEndpoint,
|
||||
tls: GatewayTlsParams?,
|
||||
): Boolean {
|
||||
if (isLocalCleartextGatewayHost(endpoint.host)) return true
|
||||
if (isLoopbackGatewayHost(endpoint.host)) {
|
||||
return true
|
||||
}
|
||||
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
|
||||
@@ -36,12 +35,7 @@ class ConnectionManager(
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
val cleartextAllowedHost =
|
||||
if (isManual) {
|
||||
isLocalCleartextGatewayHost(endpoint.host)
|
||||
} else {
|
||||
isLoopbackGatewayHost(endpoint.host)
|
||||
}
|
||||
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled && cleartextAllowedHost) return null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.gateway.isLocalCleartextGatewayHost
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
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 =
|
||||
"Public gateways require wss:// or Tailscale Serve. ws:// is allowed for localhost, the Android emulator, and private LAN IPs."
|
||||
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator."
|
||||
private const val remoteGatewaySecurityFix =
|
||||
"Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access."
|
||||
"Use localhost/the Android emulator, or enable Tailscale Serve / expose a wss:// gateway URL."
|
||||
|
||||
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 && !isLocalCleartextGatewayHost(host)) {
|
||||
if (!tls && !isLoopbackGatewayHost(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: public routes require wss:// or Tailscale Serve; ws:// is allowed for localhost, the Android emulator, and private LAN IPs
|
||||
- remember: Tailscale/public mobile routes require wss:// or Tailscale Serve; ws:// is loopback-only
|
||||
- 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_manualPrivateLanRespectsManualTlsToggle() {
|
||||
fun resolveTlsParamsForEndpoint_manualPrivateLanForcesTlsWhenToggleIsOff() {
|
||||
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
|
||||
|
||||
val params =
|
||||
@@ -119,21 +119,9 @@ class ConnectionManagerTest {
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
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)
|
||||
assertEquals(true, params?.required)
|
||||
assertNull(params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -257,11 +245,11 @@ class ConnectionManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
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"))
|
||||
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"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -99,18 +99,9 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointAllowsPrivateLanCleartextWsUrls() {
|
||||
fun parseGatewayEndpointRejectsPrivateLanCleartextWsUrls() {
|
||||
val parsed = parseGatewayEndpoint("ws://192.168.1.20:18789")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "192.168.1.20",
|
||||
port = 18789,
|
||||
tls = false,
|
||||
displayUrl = "http://192.168.1.20:18789",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
assertNull(parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -155,13 +146,9 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointAllowsLinkLocalIpv6ZoneCleartextWsUrls() {
|
||||
fun parseGatewayEndpointRejectsLinkLocalIpv6ZoneCleartextWsUrls() {
|
||||
val parsed = parseGatewayEndpoint("ws://[fe80::1%25eth0]")
|
||||
|
||||
assertEquals("fe80::1%25eth0", parsed?.host)
|
||||
assertEquals(18789, parsed?.port)
|
||||
assertEquals(false, parsed?.tls)
|
||||
assertEquals("http://[fe80::1%25eth0]:18789", parsed?.displayUrl)
|
||||
assertNull(parsed)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -262,16 +249,6 @@ 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 =
|
||||
@@ -300,19 +277,10 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointResultAllowsPrivateLanCleartextGateway() {
|
||||
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
|
||||
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "192.168.1.20",
|
||||
port = 18789,
|
||||
tls = false,
|
||||
displayUrl = "http://192.168.1.20:18789",
|
||||
),
|
||||
parsed.config,
|
||||
)
|
||||
assertNull(parsed.error)
|
||||
assertNull(parsed.config)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -453,7 +421,7 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveGatewayConnectConfigAllowsPrivateLanManualCleartextEndpoint() {
|
||||
fun resolveGatewayConnectConfigRejectsPrivateLanManualCleartextEndpoint() {
|
||||
val resolved =
|
||||
resolveGatewayConnectConfig(
|
||||
useSetupCode = false,
|
||||
@@ -469,9 +437,7 @@ class GatewayConfigResolverTest {
|
||||
fallbackPassword = "",
|
||||
)
|
||||
|
||||
assertEquals("192.168.31.100", resolved?.host)
|
||||
assertEquals(18789, resolved?.port)
|
||||
assertEquals(false, resolved?.tls)
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "openclaw-icon.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.8 KiB |
47
apps/ios/Sources/Chat/ChatSheet.swift
Normal file
47
apps/ios/Sources/Chat/ChatSheet.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
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,162 +6,30 @@ 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
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
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)
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.gateway.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func listSessions(limit: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
let json = try Self.makeListSessionsParamsJSON(limit: limit)
|
||||
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 res = try await self.gateway.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: res)
|
||||
}
|
||||
@@ -172,17 +40,23 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
}
|
||||
|
||||
func resetSession(sessionKey: String) async throws {
|
||||
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func compactSession(sessionKey: String) async throws {
|
||||
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
|
||||
struct Params: Codable { var key: String }
|
||||
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
let json = try Self.makeHistoryParamsJSON(sessionKey: sessionKey)
|
||||
struct Params: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.gateway.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
|
||||
return try JSONDecoder().decode(OpenClawChatHistoryPayload.self, from: res)
|
||||
}
|
||||
@@ -199,52 +73,35 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
|
||||
+ "len=\(message.count) attachments=\(attachments.count)"
|
||||
Self.logger.info(
|
||||
"\(startLogMessage, privacy: .public)")
|
||||
GatewayDiagnostics.log(startLogMessage)
|
||||
let json = try Self.makeChatSendParamsJSON(
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
var thinking: String
|
||||
var attachments: [OpenClawChatAttachmentPayload]?
|
||||
var timeoutMs: Int
|
||||
var idempotencyKey: String
|
||||
}
|
||||
|
||||
let params = Params(
|
||||
sessionKey: sessionKey,
|
||||
message: message,
|
||||
thinking: thinking,
|
||||
idempotencyKey: idempotencyKey,
|
||||
attachments: attachments)
|
||||
attachments: attachments.isEmpty ? nil : attachments,
|
||||
timeoutMs: 30000,
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
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)
|
||||
|
||||
@@ -1,690 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
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?
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
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)"
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,724 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,766 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,692 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
@@ -1,167 +0,0 @@
|
||||
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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,807 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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,6 +2,7 @@ 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
|
||||
@@ -15,6 +16,7 @@ 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")
|
||||
@@ -48,6 +50,7 @@ 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
|
||||
@@ -144,8 +147,7 @@ private struct ExecApprovalPromptCard: View {
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(18)
|
||||
.proPanelSurface(tint: OpenClawBrand.accentHot, radius: 20, isProminent: true)
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
|
||||
@@ -25,53 +25,4 @@ 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,12 +131,6 @@ 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
|
||||
|
||||
@@ -187,8 +181,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
forceReconnect: Bool = false) async -> String?
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -252,8 +245,7 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
forceReconnect: forceReconnect)
|
||||
password: password)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -265,8 +257,7 @@ final class GatewayConnectionController {
|
||||
host: String,
|
||||
port: Int,
|
||||
useTLS: Bool,
|
||||
authOverride: ManualAuthOverride? = nil,
|
||||
forceReconnect: Bool = false) async
|
||||
authOverride: ManualAuthOverride? = nil) async
|
||||
{
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let token =
|
||||
@@ -328,38 +319,27 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
forceReconnect: forceReconnect)
|
||||
password: password)
|
||||
}
|
||||
|
||||
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, forceReconnect: true)
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else {
|
||||
_ = await self.connectSavedManualEndpointFallback()
|
||||
return
|
||||
}
|
||||
_ = await self.connectDiscoveredGateway(gateway, forceReconnect: true)
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
_ = await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -367,7 +347,7 @@ final class GatewayConnectionController {
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: nodeOptions)
|
||||
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
||||
appModel.applyGatewayConnectConfig(refreshedConfig)
|
||||
}
|
||||
|
||||
@@ -542,14 +522,32 @@ final class GatewayConnectionController {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
|
||||
self.startLastKnownAutoConnect(
|
||||
lastKnown,
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password)
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@@ -584,44 +582,6 @@ 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() {
|
||||
@@ -634,46 +594,6 @@ 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")?
|
||||
@@ -695,14 +615,16 @@ final class GatewayConnectionController {
|
||||
tls: GatewayTLSParams?,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
forceReconnect: Bool = false)
|
||||
password: String?)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
Task { [weak self, weak appModel] in
|
||||
guard let self, let appModel else { return }
|
||||
let nodeOptions = await self.makeConnectOptions(stableID: gatewayStableID)
|
||||
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||
|
||||
Task { [weak appModel] in
|
||||
guard let appModel else { return }
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
let cfg = GatewayConnectConfig(
|
||||
url: url,
|
||||
stableID: gatewayStableID,
|
||||
@@ -710,8 +632,8 @@ final class GatewayConnectionController {
|
||||
token: token,
|
||||
bootstrapToken: bootstrapToken,
|
||||
password: password,
|
||||
nodeOptions: nodeOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg, forceReconnect: forceReconnect)
|
||||
nodeOptions: connectOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,9 +824,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GatewayConnectionController {
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
let scheme = useTLS ? "wss" : "ws"
|
||||
var components = URLComponents()
|
||||
@@ -932,18 +852,17 @@ extension GatewayConnectionController {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions(stableID: String?) async -> GatewayConnectOptions {
|
||||
private func makeConnectOptions(stableID: String?) -> 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: permissions,
|
||||
permissions: self.currentPermissions(),
|
||||
clientId: resolvedClientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
@@ -1081,16 +1000,14 @@ extension GatewayConnectionController {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentPermissions() async -> [String: Bool] {
|
||||
private func currentPermissions() -> [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
|
||||
let locationStatus = CLLocationManager().authorizationStatus
|
||||
let locationServicesEnabled = await Self.locationServicesEnabled()
|
||||
permissions["location"] = Self.isLocationAvailable(
|
||||
servicesEnabled: locationServicesEnabled,
|
||||
status: locationStatus)
|
||||
permissions["location"] = Self.isLocationAuthorized(
|
||||
status: CLLocationManager().authorizationStatus)
|
||||
&& CLLocationManager.locationServicesEnabled()
|
||||
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
||||
|
||||
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
@@ -1117,19 +1034,12 @@ extension GatewayConnectionController {
|
||||
return permissions
|
||||
}
|
||||
|
||||
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 }
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return true
|
||||
true
|
||||
default:
|
||||
return false
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,12 +1066,8 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
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_currentPermissions() -> [String: Bool] {
|
||||
self.currentPermissions()
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
@@ -1206,14 +1112,6 @@ 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,8 +3,6 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct GatewayProblemBanner: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let problem: GatewayConnectionProblem
|
||||
var primaryActionTitle: String?
|
||||
var onPrimaryAction: (() -> Void)?
|
||||
@@ -59,15 +57,9 @@ struct GatewayProblemBanner: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.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)
|
||||
}
|
||||
.background(
|
||||
.thinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
|
||||
605
apps/ios/Sources/HomeToolbar.swift
Normal file
605
apps/ios/Sources/HomeToolbar.swift
Normal file
@@ -0,0 +1,605 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user