mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-14 18:19:02 +08:00
Compare commits
25 Commits
codex/secu
...
v2026.6.9-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd92eed29 | ||
|
|
7c16b0219f | ||
|
|
33366f51af | ||
|
|
d564223cbd | ||
|
|
f1008bf948 | ||
|
|
b13c54a342 | ||
|
|
797b33128e | ||
|
|
1022e49adc | ||
|
|
e0c378d046 | ||
|
|
9c08945c36 | ||
|
|
3af4991fd2 | ||
|
|
59ecff6d92 | ||
|
|
f5ed45174d | ||
|
|
1e74b8c081 | ||
|
|
9d38be36b4 | ||
|
|
17a4a7f7d7 | ||
|
|
d5ba0c25cb | ||
|
|
9afb3acff7 | ||
|
|
6a06a36f26 | ||
|
|
aea32e6b6c | ||
|
|
08f4ff46da | ||
|
|
5ed103e79d | ||
|
|
1c2c301701 | ||
|
|
066ed9dd84 | ||
|
|
a7a5d28b57 |
@@ -19,7 +19,7 @@ attribution.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Target base version: `YYYY.M.PATCH`, without beta suffix.
|
||||
- Target base version: `YYYY.M.D`, without beta suffix.
|
||||
- Base tag: last reachable shipped release tag, usually the previous stable or
|
||||
the previous beta train requested by the operator.
|
||||
- Target ref: exact branch/SHA being released.
|
||||
@@ -37,7 +37,7 @@ attribution.
|
||||
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 one stable-base section only:
|
||||
- use `## YYYY.M.PATCH`
|
||||
- use `## YYYY.M.D`
|
||||
- do not create beta-specific headings
|
||||
- do not leave a stale `## Unreleased` section above the target release
|
||||
- if `Unreleased` contains release-bound notes, fold them into the target
|
||||
@@ -93,7 +93,7 @@ attribution.
|
||||
10. Validate and ship:
|
||||
- `git diff --check`
|
||||
- for docs/changelog-only changes, no broad tests are required
|
||||
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.PATCH notes" CHANGELOG.md`
|
||||
- commit with `scripts/committer "docs(changelog): refresh YYYY.M.D notes" CHANGELOG.md`
|
||||
- push, pull/rebase if needed, then branch/rebase release from latest `main`
|
||||
|
||||
## Quota / API Outage Rule
|
||||
|
||||
@@ -36,8 +36,8 @@ Do not update these from mixed sources. All three ASC fields must come from the
|
||||
## Workflow Shape
|
||||
|
||||
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
|
||||
- Use `source_ref=release/YYYY.M.PATCH` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.PATCH` pointing at the original stable release commit.
|
||||
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
|
||||
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
|
||||
- Real mac publish must reuse:
|
||||
- a successful private mac preflight run for the same tag/source SHA
|
||||
- a successful private mac validation run for the same tag/source SHA
|
||||
@@ -56,37 +56,37 @@ Private preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f source_ref=release/YYYY.M.PATCH \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D \
|
||||
-f preflight_only=true \
|
||||
-f smoke_test_only=false \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Private validation for a branch-variation preflight:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f source_ref=release/YYYY.M.PATCH
|
||||
-f tag=vYYYY.M.D \
|
||||
-f source_ref=release/YYYY.M.D
|
||||
```
|
||||
|
||||
Real publish:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
|
||||
-f tag=vYYYY.M.PATCH \
|
||||
-f tag=vYYYY.M.D \
|
||||
-f preflight_only=false \
|
||||
-f smoke_test_only=false \
|
||||
-f preflight_run_id=<successful-preflight-run> \
|
||||
-f validate_run_id=<successful-validation-run> \
|
||||
-f allow_late_calver_recovery=false \
|
||||
-f public_release_branch=release/YYYY.M.PATCH
|
||||
-f public_release_branch=release/YYYY.M.D
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
- `gh release view vYYYY.M.PATCH --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.PATCH.zip`.
|
||||
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
|
||||
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
|
||||
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.
|
||||
|
||||
@@ -10,15 +10,12 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
## Respect release guardrails
|
||||
|
||||
- Do not change version numbers without explicit operator approval.
|
||||
- Versions use `YYYY.M.PATCH`, where `PATCH` is the sequential release-train number within the month, not the calendar day.
|
||||
- Choose a new beta train from stable and beta releases only. Alpha-only tags do not consume or advance the beta/stable patch number. Continue the highest existing unpublished/published beta train with the next `beta.N` when appropriate; otherwise increment the highest stable/beta patch by one and start at `beta.1`.
|
||||
- Example: after stable `2026.6.5`, the next new beta train is `2026.6.6-beta.1`, even if automated alpha-only tags such as `2026.6.10-alpha.1` exist.
|
||||
- Ask permission before any npm publish or release step.
|
||||
- This skill should be sufficient to drive the normal release flow end-to-end.
|
||||
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
|
||||
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
|
||||
- Normal release work happens on a branch cut from `main`, not directly on
|
||||
`main`. Use `release/YYYY.M.PATCH` for the branch name.
|
||||
`main`. Use `release/YYYY.M.D` for the branch name.
|
||||
- If the operator asks for a release without saying stable/full, default to
|
||||
beta only. Continue from beta to stable only when the operator explicitly asks
|
||||
for the full release or an automated beta-and-stable train.
|
||||
@@ -95,7 +92,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
## Keep release channel naming aligned
|
||||
|
||||
- `stable`: tagged releases only, published to npm `beta` by default; operators may target npm `latest` explicitly or promote later
|
||||
- `beta`: prerelease tags like `vYYYY.M.PATCH-beta.N`, with npm dist-tag `beta`
|
||||
- `beta`: prerelease tags like `vYYYY.M.D-beta.N`, with npm dist-tag `beta`
|
||||
- Prefer `-beta.N`; do not mint new `-1` or `-2` beta suffixes
|
||||
- `dev`: moving head on `main`
|
||||
- When using a beta Git tag, publish npm with the matching beta version suffix so the plain version is not consumed or blocked
|
||||
@@ -111,7 +108,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- `docs/install/updating.md`
|
||||
- Peekaboo Xcode project and plist version fields
|
||||
- Before creating a release tag, make every version location above match the version encoded by that tag.
|
||||
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version locations still stay at `YYYY.M.PATCH`.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
- Every stable OpenClaw release ships the npm package, macOS app, and signed
|
||||
@@ -132,19 +129,19 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
tagged commit when the delta is mac packaging, signing, workflow, or
|
||||
validation-only release machinery. If mac packaging needs release-branch-only
|
||||
fixes after the stable npm package or GitHub tag is already published, do not
|
||||
create a `vYYYY.M.PATCH-N` correction tag just to change the workflow source.
|
||||
Dispatch the private mac workflows for the original `tag=vYYYY.M.PATCH` with
|
||||
`source_ref=release/YYYY.M.PATCH` and `public_release_branch=release/YYYY.M.PATCH`;
|
||||
create a `vYYYY.M.D-N` correction tag just to change the workflow source.
|
||||
Dispatch the private mac workflows for the original `tag=vYYYY.M.D` with
|
||||
`source_ref=release/YYYY.M.D` and `public_release_branch=release/YYYY.M.D`;
|
||||
provenance checks must prove the source SHA descends from the tag and
|
||||
validation/preflight use the same source. Reserve `vYYYY.M.PATCH-N` correction
|
||||
validation/preflight use the same source. Reserve `vYYYY.M.D-N` correction
|
||||
tags for emergency hotfixes that must publish a new npm package/release
|
||||
identity, not for ordinary mac-only packaging recovery.
|
||||
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
|
||||
- That shared production Sparkle feed is stable-only. Beta mac releases may
|
||||
upload assets to the GitHub prerelease, but they must not replace the shared
|
||||
`appcast.xml` unless a separate beta feed exists.
|
||||
- For fallback correction tags like `vYYYY.M.PATCH-N`, the repo version still stays
|
||||
at `YYYY.M.PATCH`, but the mac release must use a strictly higher numeric
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
|
||||
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
|
||||
`APP_BUILD` / Sparkle build than the original release so existing installs
|
||||
see it as newer.
|
||||
- Stable Windows Hub release closeout requires the signed
|
||||
@@ -154,7 +151,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
workflow after the matching `openclaw/openclaw-windows-node` release exists;
|
||||
it verifies Authenticode signatures on Windows before uploading assets.
|
||||
- Website Windows Hub download links should target exact canonical
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.PATCH/...` assets for the current
|
||||
`openclaw/openclaw/releases/download/vYYYY.M.D/...` assets for the current
|
||||
stable release, or `releases/latest/download/...` only after verifying the
|
||||
redirect resolves to that same tag, so the installable signed Windows artifact
|
||||
is visible from both the GitHub release page and openclaw.ai.
|
||||
@@ -168,7 +165,7 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
beta release tag as the base, then inspect every commit through the target
|
||||
release SHA.
|
||||
- The changelog rewrite is not optional for beta reruns: any `beta.N` after a
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.PATCH` section
|
||||
rebase or backport must refresh the same stable-base `## YYYY.M.D` section
|
||||
before the new version/tag commit.
|
||||
- Include both merged PR commits and direct commits on `main`. Direct commits
|
||||
matter: infer notes from their subject, body, touched files, linked issues,
|
||||
@@ -191,11 +188,11 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
or editing a release, extract from `## YYYY.M.PATCH` through the line before the
|
||||
or editing a release, extract from `## YYYY.M.D` through the line before the
|
||||
next level-2 heading and use that complete block as the release notes.
|
||||
- To update an existing GitHub Release body, resolve the numeric release id and
|
||||
patch that resource with the notes file as the `body` field:
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.PATCH --jq .id`, then
|
||||
`gh api repos/openclaw/openclaw/releases/tags/vYYYY.M.D --jq .id`, then
|
||||
`gh api -X PATCH repos/openclaw/openclaw/releases/<id> -F body=@/tmp/notes.md`.
|
||||
Do not trust `gh release edit --notes-file` or `--input` JSON if verification
|
||||
disagrees; verify with `gh api repos/openclaw/openclaw/releases/<id>` because
|
||||
@@ -208,10 +205,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
record's `docsPath` or `/plugins/compatibility` when no more specific
|
||||
deprecation page exists.
|
||||
- When cutting a mac release with a beta GitHub prerelease:
|
||||
- tag `vYYYY.M.PATCH-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.PATCH-beta.N`
|
||||
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||
- use release notes from the stable base `CHANGELOG.md` version section
|
||||
(`## YYYY.M.PATCH`), not a beta-specific heading
|
||||
(`## YYYY.M.D`), not a beta-specific heading
|
||||
- attach at least the zip and dSYM zip, plus dmg if available
|
||||
- Keep the top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first
|
||||
@@ -221,10 +218,10 @@ Use this skill for release and publish-time workflow. Load `$release-private` if
|
||||
|
||||
Use the OpenClaw account's existing release-post style:
|
||||
|
||||
- Format: `OpenClaw YYYY.M.PATCH 🦞` or `🦞 OpenClaw YYYY.M.PATCH is live`, blank line,
|
||||
- Format: `OpenClaw YYYY.M.D 🦞` or `🦞 OpenClaw YYYY.M.D is live`, blank line,
|
||||
then 3-4 emoji-led bullets, blank line, one short punchline, then the release
|
||||
link.
|
||||
- For beta: say `OpenClaw YYYY.M.PATCH-beta.N 🦞` or `OpenClaw YYYY.M.PATCH beta N is
|
||||
- For beta: say `OpenClaw YYYY.M.D-beta.N 🦞` or `OpenClaw YYYY.M.D beta N is
|
||||
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
|
||||
@@ -317,23 +314,6 @@ pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
- Before tagging, diff publishable plugin package manifests against the last
|
||||
reachable stable/beta release tag. For every newly publishable package
|
||||
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
|
||||
package name did not exist in the base tag, verify the target registry package
|
||||
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
|
||||
package first. Do not hide or disable release surfaces just to unblock a
|
||||
train unless the owner explicitly decides the plugin should not ship in that
|
||||
release; first-package registry ownership is release prep, not product
|
||||
rollback. The mint/prepublish path must either be the real release publish
|
||||
path for the auto-bumped beta version, or a deliberately non-consuming
|
||||
registry-prep step that cannot occupy the next beta version/tag. Confirm
|
||||
registry owner, npm scope/package-creation permission, provenance path, and
|
||||
first-package publish plan before the full release publish continues. Useful
|
||||
npm probe:
|
||||
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
|
||||
a package newly added to the release is a release-prep blocker, not something
|
||||
to discover from the publish job.
|
||||
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
|
||||
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
|
||||
`otel-trace-smoke`, and checks span names plus content/identifier redaction
|
||||
@@ -352,8 +332,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
```
|
||||
|
||||
- This verifies the published registry install path in a fresh temp prefix.
|
||||
- For stable correction releases like `YYYY.M.PATCH-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.PATCH` to `YYYY.M.PATCH-N` so a correction publish cannot
|
||||
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
|
||||
silently leave existing global installs on the old base stable payload.
|
||||
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
|
||||
now fails the candidate update tarball when npm reports an oversized
|
||||
@@ -500,7 +480,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
`npm login --auth-type=legacy`, then confirm `npm whoami` reports
|
||||
`steipete`.
|
||||
- Promote with a fresh OTP:
|
||||
`npm dist-tag add openclaw@YYYY.M.PATCH latest --otp "$OTP"`.
|
||||
`npm dist-tag add openclaw@YYYY.M.D latest --otp "$OTP"`.
|
||||
- Verify with a cache-bypassed registry read, for example:
|
||||
`npm view openclaw dist-tags --json --prefer-online --cache /tmp/openclaw-npm-cache-verify-$$`
|
||||
and `npm view openclaw@latest version dist.tarball --json --prefer-online`.
|
||||
@@ -526,7 +506,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
the npm version is already published.
|
||||
- npm validation-only preflight may still be dispatched from ordinary branches
|
||||
when testing workflow changes before merge. Release checks and real publish
|
||||
use only `main` or `release/YYYY.M.PATCH`.
|
||||
use only `main` or `release/YYYY.M.D`.
|
||||
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
|
||||
public validation-only handoff. It validates the tag/release state and points
|
||||
operators to the private repo. It still rebuilds the JS outputs needed for
|
||||
@@ -551,7 +531,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
waives the full gate; mac beta validation is still only required when
|
||||
requested.
|
||||
- Real publish runs may be dispatched from `main` or from a
|
||||
`release/YYYY.M.PATCH` branch. For release-branch runs, the tag must be contained
|
||||
`release/YYYY.M.D` branch. For release-branch runs, the tag must be contained
|
||||
in that release branch, and the real publish must reuse a successful preflight
|
||||
from the same branch.
|
||||
- The release workflows stay tag-based; rely on the documented release sequence
|
||||
@@ -579,11 +559,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
|
||||
does not support trusted publishing for `npm dist-tag add`.
|
||||
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||
- Publishable plugins that are new to npm require owner-led first-package
|
||||
minting before the full release publish. Do not consume the next beta version
|
||||
with an ad-hoc manual package publish; use the release-owned auto-bumped
|
||||
version path, or a non-consuming registry setup/preflight step. Bundled
|
||||
disk-tree-only plugins stay unpublished.
|
||||
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||
|
||||
## Fallback local mac publish
|
||||
|
||||
@@ -623,8 +599,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
4. Pull latest `main` and confirm current `main` CI is green.
|
||||
5. Run `/changelog` for the stable base target version on `main`, commit the
|
||||
changelog rewrite immediately, push, and pull/rebase. For beta releases,
|
||||
keep the changelog heading as `## YYYY.M.PATCH`, not `## YYYY.M.PATCH-beta.N`.
|
||||
6. Create `release/YYYY.M.PATCH` from that post-changelog `main` commit.
|
||||
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.
|
||||
9. Immediately dispatch Actions > `OpenClaw Performance` from `main` with
|
||||
@@ -640,9 +616,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
mac app, signing, notarization, and appcast path.
|
||||
12. Confirm the target npm version is not already published.
|
||||
13. Create and push the git tag from the release branch.
|
||||
14. Do not create or publish the matching GitHub release page yet. The real
|
||||
publish workflow creates or undrafts it only after postpublish verification
|
||||
and release evidence upload pass.
|
||||
14. Create or refresh the matching GitHub release.
|
||||
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
|
||||
for the mock parity, live Matrix, and live Telegram credentialed-channel
|
||||
lanes to pass.
|
||||
@@ -665,29 +639,20 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
with `preflight_only=true` and wait for it to pass. Save that run id because
|
||||
the real publish requires it to reuse the notarized mac artifacts.
|
||||
21. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
delete the tag and any accidental draft/incomplete GitHub release, recreate
|
||||
the tag from the fixed commit, and rerun all relevant preflights from
|
||||
scratch before continuing. Never reuse old preflight results after the
|
||||
commit changes. Once the npm version exists, do not rerun the publish
|
||||
workflow for that same version; finalize the existing draft/evidence state
|
||||
manually or cut a correction tag. For pushed or published beta tags, do not
|
||||
delete/recreate; increment to the next beta tag. For preflight-only failures
|
||||
where npm did not publish the beta version, delete/recreate the same beta
|
||||
tag and any accidental draft/incomplete prerelease at the fixed commit
|
||||
instead of skipping a prerelease number.
|
||||
delete the tag and matching GitHub release, recreate them from the fixed
|
||||
commit, and rerun all relevant preflights from scratch before continuing.
|
||||
Never reuse old preflight results after the commit changes. For pushed or
|
||||
published beta tags, do not delete/recreate; increment to the next beta tag.
|
||||
For preflight-only failures where npm did not publish the beta version,
|
||||
delete/recreate the same beta tag and prerelease at the fixed commit instead
|
||||
of skipping a prerelease number.
|
||||
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
|
||||
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
|
||||
`latest` only when you intentionally want direct stable publish), keep it
|
||||
the same as the preflight run, and pass the successful npm
|
||||
`preflight_run_id`.
|
||||
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
24. Wait for the real publish workflow to run postpublish verification,
|
||||
create or update the GitHub release as a draft, upload dependency evidence,
|
||||
append release verification proof, and only then undraft/publish it. If a
|
||||
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
|
||||
the release draft with OpenClaw npm evidence and exits red; do not undraft
|
||||
until the plugin publish gap is repaired. The standalone verifier command
|
||||
remains the recovery probe:
|
||||
24. Run postpublish verification:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
25. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
|
||||
@@ -37,11 +37,9 @@ This is good for auditability if commits are clearly machine-authored and gated
|
||||
- Branch name: `tideclaw/alpha/YYYY-MM-DD-HHMMZ`
|
||||
- Base: current `origin/main` SHA at trigger time.
|
||||
- State file: resolve from `$release-private` on the Tideclaw host.
|
||||
- Release tag: `vYYYY.M.PATCH-alpha.N`
|
||||
- Release tag: `vYYYY.M.D-alpha.N`
|
||||
- npm dist-tag: `alpha`
|
||||
|
||||
`PATCH` is a sequential monthly release-train number, never the calendar day. Determine the alpha train from stable and beta releases; ignore alpha-only patch numbers when choosing the next train. Use one greater than the highest stable/beta patch for the month, then increment only `alpha.N` for repeated nightlies on that train. If a beta exists on that next patch, move alpha to the following train. Legacy alpha-only tags with inflated patch numbers do not advance beta/stable numbering.
|
||||
|
||||
Do not reuse old alpha branches for a new run. If rerunning the same base SHA, create a new timestamped branch and record why.
|
||||
|
||||
## Start
|
||||
@@ -100,7 +98,7 @@ Tideclaw may run beta releases from `#releases` or mentioned `#maintainers` comm
|
||||
Accepted shapes:
|
||||
|
||||
```text
|
||||
@Tideclaw beta release from vYYYY.M.PATCH-alpha.N
|
||||
@Tideclaw beta release from vYYYY.M.D-alpha.N
|
||||
@Tideclaw beta release from tideclaw/alpha/YYYY-MM-DD-HHMMZ
|
||||
@Tideclaw beta release from latest proven alpha
|
||||
```
|
||||
@@ -112,7 +110,7 @@ Rules:
|
||||
3. Verify the source alpha first: GitHub release, npm `alpha` package, release CI, recorded state file, and branch/tag SHA.
|
||||
4. Create a fresh beta branch `tideclaw/beta/YYYY-MM-DD-HHMMZ` from the proven alpha source, not directly from a moving `main`.
|
||||
5. Reuse/squash only stabilization fixes already proven on alpha. Do not import unrelated alpha release mechanics unless the beta release docs require them.
|
||||
6. Compute beta as `vYYYY.M.PATCH-beta.N`, matching npm `--tag beta`. Ignore alpha-only patch numbers when selecting the beta train.
|
||||
6. Compute beta as `vYYYY.M.D-beta.N`, matching npm `--tag beta`.
|
||||
7. Run beta release validation/preflight/full release CI and fix failures on the beta branch.
|
||||
8. Publish beta only after green beta gates. Use GitHub Actions/OIDC, never direct npm publish from the host.
|
||||
9. Final Discord summary must include source alpha, beta tag/version, branch, fix commits, workflow run IDs, npm/GitHub proof, and any skipped/blocked reason.
|
||||
@@ -167,7 +165,7 @@ git push -u origin "$BRANCH"
|
||||
|
||||
After local proof:
|
||||
|
||||
1. Compute the next `vYYYY.M.PATCH-alpha.N` from existing git tags, npm versions, and GitHub releases. Select `PATCH` from stable/beta trains, not the date or the highest alpha-only patch. Reuse the same alpha train and increment `alpha.N` until that patch has a beta; after a beta exists, use the following patch for new alpha builds.
|
||||
1. Compute the next `vYYYY.M.D-alpha.N` from existing git tags, npm versions, and GitHub releases.
|
||||
2. Make the alpha branch package version and release metadata match that tag, commit it, and push the branch.
|
||||
3. Run release validation from the alpha branch, using GitHub CLI, not browser/fetch tools. On the Tideclaw host, bare `gh` is a read-only Codex sandbox wrapper; use `/usr/local/bin/gh-tideclaw-write` for write-capable commands such as `workflow run`, `run cancel`, and publish dispatch:
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
name: openclaw-codeql-process-exec-boundary-critical-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
security-severity: /([7-9]|10)\.(\d)+/
|
||||
|
||||
paths:
|
||||
- src/process
|
||||
- src/tui/tui-local-shell.ts
|
||||
- src/tui/tui.ts
|
||||
- src/plugin-sdk/windows-spawn.ts
|
||||
- packages/agent-core/src/harness/env
|
||||
- packages/memory-host-sdk/src/host
|
||||
- extensions/acpx/src
|
||||
- extensions/bonjour/src/advertiser.ts
|
||||
- extensions/browser/src/browser/chrome-mcp.ts
|
||||
- extensions/browser/src/browser/chrome.executables.ts
|
||||
- extensions/browser/src/browser/chrome.ts
|
||||
- extensions/codex/src/app-server/sandbox-exec-server
|
||||
- extensions/codex/src/app-server/transport-stdio.ts
|
||||
- extensions/codex/src/node-cli-sessions.ts
|
||||
- extensions/codex-supervisor/src/json-rpc-client.ts
|
||||
- extensions/file-transfer/src
|
||||
- extensions/google-meet/src
|
||||
- extensions/imessage/src
|
||||
- extensions/memory-core/src/memory/qmd-manager.ts
|
||||
- extensions/memory-wiki/src/obsidian.ts
|
||||
- extensions/microsoft-foundry/cli.ts
|
||||
- extensions/ollama/src/wsl2-crash-loop-check.ts
|
||||
- extensions/qa-lab/src
|
||||
- extensions/signal/src/daemon.ts
|
||||
- extensions/tts-local-cli/speech-provider.ts
|
||||
- extensions/voice-call/src
|
||||
- scripts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*-runtime.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.spec.ts"
|
||||
- "**/*.spec.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
47
.github/workflows/codeql.yml
vendored
47
.github/workflows/codeql.yml
vendored
@@ -17,28 +17,7 @@ on:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "extensions/acpx/src/**"
|
||||
- "extensions/bonjour/src/advertiser.ts"
|
||||
- "extensions/browser/src/browser/chrome-mcp.ts"
|
||||
- "extensions/browser/src/browser/chrome.executables.ts"
|
||||
- "extensions/browser/src/browser/chrome.ts"
|
||||
- "extensions/codex/src/app-server/sandbox-exec-server/**"
|
||||
- "extensions/codex/src/app-server/transport-stdio.ts"
|
||||
- "extensions/codex/src/node-cli-sessions.ts"
|
||||
- "extensions/codex-supervisor/src/json-rpc-client.ts"
|
||||
- "extensions/file-transfer/src/**"
|
||||
- "extensions/google-meet/src/**"
|
||||
- "extensions/imessage/src/**"
|
||||
- "extensions/memory-core/src/memory/qmd-manager.ts"
|
||||
- "extensions/memory-wiki/src/obsidian.ts"
|
||||
- "extensions/microsoft-foundry/cli.ts"
|
||||
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
|
||||
- "extensions/qa-lab/src/**"
|
||||
- "extensions/signal/src/daemon.ts"
|
||||
- "extensions/tts-local-cli/speech-provider.ts"
|
||||
- "extensions/voice-call/src/**"
|
||||
- "packages/**"
|
||||
- "scripts/**"
|
||||
- "src/**"
|
||||
push:
|
||||
branches:
|
||||
@@ -47,28 +26,7 @@ on:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "extensions/acpx/src/**"
|
||||
- "extensions/bonjour/src/advertiser.ts"
|
||||
- "extensions/browser/src/browser/chrome-mcp.ts"
|
||||
- "extensions/browser/src/browser/chrome.executables.ts"
|
||||
- "extensions/browser/src/browser/chrome.ts"
|
||||
- "extensions/codex/src/app-server/sandbox-exec-server/**"
|
||||
- "extensions/codex/src/app-server/transport-stdio.ts"
|
||||
- "extensions/codex/src/node-cli-sessions.ts"
|
||||
- "extensions/codex-supervisor/src/json-rpc-client.ts"
|
||||
- "extensions/file-transfer/src/**"
|
||||
- "extensions/google-meet/src/**"
|
||||
- "extensions/imessage/src/**"
|
||||
- "extensions/memory-core/src/memory/qmd-manager.ts"
|
||||
- "extensions/memory-wiki/src/obsidian.ts"
|
||||
- "extensions/microsoft-foundry/cli.ts"
|
||||
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
|
||||
- "extensions/qa-lab/src/**"
|
||||
- "extensions/signal/src/daemon.ts"
|
||||
- "extensions/tts-local-cli/speech-provider.ts"
|
||||
- "extensions/voice-call/src/**"
|
||||
- "packages/**"
|
||||
- "scripts/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
@@ -115,11 +73,6 @@ jobs:
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: process-exec-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: plugin-trust-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
8
.github/workflows/install-smoke.yml
vendored
8
.github/workflows/install-smoke.yml
vendored
@@ -112,7 +112,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -245,7 +245,7 @@ jobs:
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
if: steps.existing.outputs.exists != 'true'
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -429,7 +429,7 @@ jobs:
|
||||
run: timeout --kill-after=30s 600s docker pull "$IMAGE_REF"
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -542,7 +542,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -437,17 +437,8 @@ jobs:
|
||||
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
|
||||
fi
|
||||
|
||||
read_discord_status_reaction_status() {
|
||||
local lane="$1"
|
||||
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
||||
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
|
||||
return
|
||||
fi
|
||||
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
|
||||
}
|
||||
|
||||
baseline_status="$(read_discord_status_reaction_status baseline)"
|
||||
candidate_status="$(read_discord_status_reaction_status candidate)"
|
||||
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
|
||||
|
||||
jq -n \
|
||||
--arg baseline_status "$baseline_status" \
|
||||
|
||||
@@ -451,17 +451,8 @@ jobs:
|
||||
|
||||
capture_candidate_discord_web
|
||||
|
||||
read_discord_thread_attachment_status() {
|
||||
local lane="$1"
|
||||
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
||||
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
|
||||
return
|
||||
fi
|
||||
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
|
||||
}
|
||||
|
||||
baseline_status="$(read_discord_thread_attachment_status baseline)"
|
||||
candidate_status="$(read_discord_thread_attachment_status candidate)"
|
||||
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
|
||||
comparison_status="fail"
|
||||
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
|
||||
comparison_status="pass"
|
||||
|
||||
4
.github/workflows/mantis-telegram-live.yml
vendored
4
.github/workflows/mantis-telegram-live.yml
vendored
@@ -445,8 +445,8 @@ jobs:
|
||||
telegram_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
|
||||
echo "Telegram live QA did not produce an evidence summary." >&2
|
||||
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
|
||||
echo "Telegram live QA did not produce a summary." >&2
|
||||
exit "$telegram_exit"
|
||||
fi
|
||||
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
2
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -126,7 +126,7 @@ jobs:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
|
||||
@@ -1497,7 +1497,7 @@ jobs:
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.needs_build == '1'
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -1593,11 +1593,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repository="${GITHUB_REPOSITORY,,}"
|
||||
live_image_extensions="matrix,acpx"
|
||||
live_image_tag_suffix="${live_image_extensions//,/-}"
|
||||
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}-${live_image_tag_suffix}"
|
||||
live_image="ghcr.io/${repository}-live-test:${SELECTED_SHA}"
|
||||
echo "live_image=${live_image}" >> "$GITHUB_OUTPUT"
|
||||
echo "live_image_extensions=${live_image_extensions}" >> "$GITHUB_OUTPUT"
|
||||
echo "Shared live-test image: \`${live_image}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Log in to GHCR
|
||||
@@ -1620,7 +1617,7 @@ jobs:
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.exists != '1'
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
uses: useblacksmith/setup-docker-builder@722e97d12b1d06a961800dd6c05d79d951ad3c80 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
@@ -1632,7 +1629,7 @@ jobs:
|
||||
file: ./Dockerfile
|
||||
target: build
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=${{ steps.image.outputs.live_image_extensions }}
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.live_image }}
|
||||
sbom: true
|
||||
@@ -1748,7 +1745,6 @@ jobs:
|
||||
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 ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
openai) require_any OpenAI OPENAI_API_KEY ;;
|
||||
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
|
||||
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
|
||||
@@ -1837,7 +1833,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
|
||||
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
|
||||
|
||||
normalize_provider() {
|
||||
local value="${1,,}"
|
||||
@@ -1923,7 +1919,6 @@ jobs:
|
||||
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 ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
openai) require_any OpenAI OPENAI_API_KEY ;;
|
||||
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
|
||||
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
|
||||
|
||||
9
.github/workflows/openclaw-performance.yml
vendored
9
.github/workflows/openclaw-performance.yml
vendored
@@ -527,13 +527,6 @@ jobs:
|
||||
cleanup_gateway
|
||||
trap - EXIT
|
||||
|
||||
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
|
||||
pnpm test:sqlite:perf:smoke
|
||||
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
|
||||
else
|
||||
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
|
||||
--source-dir "$SOURCE_PERF_DIR" \
|
||||
--output "$SOURCE_PERF_DIR/index.md")
|
||||
@@ -611,7 +604,7 @@ jobs:
|
||||
|
||||
## Source probes
|
||||
|
||||
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
|
||||
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
301
.github/workflows/openclaw-release-publish.yml
vendored
301
.github/workflows/openclaw-release-publish.yml
vendored
@@ -387,9 +387,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dispatch_workflow_at_ref() {
|
||||
local workflow_ref="$1"
|
||||
shift
|
||||
dispatch_workflow() {
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
@@ -399,7 +397,7 @@ jobs:
|
||||
-F per_page=100 \
|
||||
--jq '[.workflow_runs[].id]')"
|
||||
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
@@ -434,10 +432,6 @@ jobs:
|
||||
printf '%s\n' "${run_id}"
|
||||
}
|
||||
|
||||
dispatch_workflow() {
|
||||
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
|
||||
}
|
||||
|
||||
print_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
@@ -659,128 +653,6 @@ jobs:
|
||||
done
|
||||
}
|
||||
|
||||
guard_existing_public_release() {
|
||||
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
|
||||
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
|
||||
if [[ "${is_draft}" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
asset_name="openclaw-${release_version}-dependency-evidence.zip"
|
||||
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
|
||||
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
|
||||
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
|
||||
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
|
||||
|
||||
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
|
||||
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
|
||||
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
|
||||
} >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
guard_openclaw_npm_not_already_published() {
|
||||
local release_version release_url
|
||||
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
|
||||
{
|
||||
echo "openclaw@${release_version} is already published on npm."
|
||||
echo "Refusing to dispatch publish child workflows for an already-published version."
|
||||
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
|
||||
echo "Release page, if present: ${release_url}"
|
||||
} >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_clawhub_release_plan() {
|
||||
local -a plan_args
|
||||
|
||||
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
|
||||
plan_args=(
|
||||
--release-tag "${RELEASE_TAG}"
|
||||
--release-publish-branch "${CHILD_WORKFLOW_REF}"
|
||||
--release-publish-run-id "${GITHUB_RUN_ID}"
|
||||
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
|
||||
)
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
plan_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
|
||||
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
|
||||
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
|
||||
|
||||
echo "Resolved OpenClaw release ClawHub dispatch plan:"
|
||||
cat "${clawhub_plan_path}"
|
||||
|
||||
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
|
||||
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
|
||||
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
|
||||
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
|
||||
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
|
||||
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
|
||||
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
|
||||
|
||||
{
|
||||
echo "### ClawHub release plan"
|
||||
echo
|
||||
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
|
||||
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
|
||||
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
|
||||
if [[ -n "${normal_plugins}" ]]; then
|
||||
echo "- Normal plugins: \`${normal_plugins}\`"
|
||||
fi
|
||||
if [[ -n "${bootstrap_plugins}" ]]; then
|
||||
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
|
||||
fi
|
||||
if [[ -n "${missing_trusted_plugins}" ]]; then
|
||||
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
append_clawhub_dispatch_args() {
|
||||
local target="$1"
|
||||
while IFS=$'\t' read -r key value; do
|
||||
clawhub_dispatch_args+=(-f "${key}=${value}")
|
||||
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
|
||||
}
|
||||
|
||||
write_clawhub_runtime_state() {
|
||||
local force_skip_clawhub="$1"
|
||||
local output_path="$2"
|
||||
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
|
||||
--force-skip-clawhub "${force_skip_clawhub}" \
|
||||
--normal-run-id "${plugin_clawhub_run_id:-}" \
|
||||
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
|
||||
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
|
||||
}
|
||||
|
||||
create_or_update_github_release() {
|
||||
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -826,17 +698,11 @@ jobs:
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag \
|
||||
--draft \
|
||||
--title "${title}" \
|
||||
--notes-file "${notes_file}" \
|
||||
"${prerelease_args[@]}" \
|
||||
"${latest_arg}"
|
||||
fi
|
||||
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
publish_github_release() {
|
||||
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
@@ -869,11 +735,9 @@ jobs:
|
||||
}
|
||||
|
||||
verify_published_release() {
|
||||
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
|
||||
local release_version evidence_path
|
||||
local -a verify_args
|
||||
|
||||
skip_clawhub="${1:-false}"
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
|
||||
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
|
||||
@@ -886,18 +750,16 @@ jobs:
|
||||
--dist-tag "${RELEASE_NPM_DIST_TAG}"
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--clawhub-workflow-ref "${clawhub_workflow_ref}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
--skip-github-release
|
||||
)
|
||||
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
|
||||
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
|
||||
while IFS= read -r arg; do
|
||||
verify_args+=("${arg}")
|
||||
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
|
||||
else
|
||||
verify_args+=(--skip-clawhub)
|
||||
fi
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
@@ -913,7 +775,7 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
@@ -927,16 +789,16 @@ jobs:
|
||||
else
|
||||
telegram_line="- npm Telegram beta E2E: not supplied"
|
||||
fi
|
||||
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
|
||||
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
|
||||
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
|
||||
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
else
|
||||
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
fi
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
RELEASE_NOTES_FILE="${notes_file}" \
|
||||
RELEASE_VERSION="${release_version}" \
|
||||
RELEASE_TAG="${RELEASE_TAG}" \
|
||||
RELEASE_SHA="${TARGET_SHA}" \
|
||||
RELEASE_REPO="${GITHUB_REPOSITORY}" \
|
||||
RELEASE_TARBALL="${tarball}" \
|
||||
RELEASE_INTEGRITY="${integrity}" \
|
||||
@@ -946,7 +808,6 @@ jobs:
|
||||
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
|
||||
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
|
||||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
@@ -964,14 +825,12 @@ 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}\``,
|
||||
`- release SHA: \`${process.env.RELEASE_SHA}\``,
|
||||
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
|
||||
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
|
||||
process.env.CLAWHUB_LINE,
|
||||
process.env.CLAWHUB_BOOTSTRAP_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
].join("\n");
|
||||
@@ -988,7 +847,6 @@ jobs:
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Release approval: this workflow job"
|
||||
@@ -1005,68 +863,26 @@ jobs:
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
guard_existing_public_release
|
||||
guard_openclaw_npm_not_already_published
|
||||
resolve_clawhub_release_plan
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
if [[ -n "${PLUGINS}" ]]; then
|
||||
npm_args+=(-f plugins="${PLUGINS}")
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
fi
|
||||
|
||||
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
|
||||
plugin_clawhub_run_id=""
|
||||
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
|
||||
clawhub_dispatch_args=()
|
||||
append_clawhub_dispatch_args normal
|
||||
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
|
||||
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
|
||||
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
|
||||
"${clawhub_dispatch_args[@]}")"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
plugin_clawhub_bootstrap_run_id=""
|
||||
plugin_clawhub_bootstrap_completed="false"
|
||||
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
|
||||
clawhub_dispatch_args=()
|
||||
append_clawhub_dispatch_args bootstrap
|
||||
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
|
||||
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
|
||||
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
|
||||
"${clawhub_dispatch_args[@]}")"
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
|
||||
{
|
||||
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
|
||||
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
|
||||
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
|
||||
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
|
||||
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
|
||||
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
|
||||
plugin_clawhub_bootstrap_completed="true"
|
||||
else
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
openclaw_npm_run_id=""
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
|
||||
@@ -1083,52 +899,19 @@ jobs:
|
||||
|
||||
clawhub_result=""
|
||||
clawhub_pid=""
|
||||
clawhub_bootstrap_result=""
|
||||
clawhub_bootstrap_pid=""
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
|
||||
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
|
||||
clawhub_bootstrap_pid="${wait_run_pid}"
|
||||
fi
|
||||
fi
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
else
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
|
||||
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
openclaw_result=""
|
||||
@@ -1151,33 +934,21 @@ jobs:
|
||||
openclaw_failed=1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
fi
|
||||
|
||||
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
if [[ "${failed}" == "0" ]]; then
|
||||
verify_published_release
|
||||
else
|
||||
verify_published_release true
|
||||
fi
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
|
||||
verify_published_release
|
||||
append_release_proof_to_github_release
|
||||
if [[ "${failed}" == "0" ]]; then
|
||||
publish_github_release
|
||||
else
|
||||
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
|
||||
504
.github/workflows/plugin-clawhub-new.yml
vendored
504
.github/workflows/plugin-clawhub-new.yml
vendored
@@ -1,504 +0,0 @@
|
||||
name: Plugin ClawHub New
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to bootstrap on ClawHub
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch name of the approving OpenClaw Release Publish workflow run
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
|
||||
|
||||
jobs:
|
||||
resolve_bootstrap_plan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
|
||||
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
env:
|
||||
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
if [[ -n "${TARGET_REF}" ]]; then
|
||||
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
|
||||
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
|
||||
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
|
||||
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
|
||||
else
|
||||
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "${target_sha}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r release_ref; do
|
||||
if git merge-base --is-ancestor HEAD "${release_ref}"; then
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
RELEASE_PLUGINS: ${{ inputs.plugins }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
|
||||
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
|
||||
exit 1
|
||||
fi
|
||||
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
|
||||
|
||||
- name: Resolve plugin bootstrap plan
|
||||
id: plan
|
||||
env:
|
||||
RELEASE_PLUGINS: ${{ inputs.plugins }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
node --import tsx scripts/plugin-clawhub-release-plan.ts \
|
||||
--selection-mode selected \
|
||||
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
|
||||
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
|
||||
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
|
||||
matrix_json="$(
|
||||
jq -c '
|
||||
[
|
||||
.bootstrapCandidates[]? + {
|
||||
bootstrapMode: "publish",
|
||||
requiresManualOverride: false
|
||||
},
|
||||
.missingTrustedPublisher[]? + {
|
||||
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
|
||||
requiresManualOverride: true
|
||||
}
|
||||
]
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
has_bootstrap_candidates="false"
|
||||
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
|
||||
has_bootstrap_candidates="true"
|
||||
fi
|
||||
|
||||
invalid_scope="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
|
||||
| select(.packageName | startswith("@openclaw/") | not)
|
||||
| "- \(.packageName)@\(.version)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid_scope}" ]]; then
|
||||
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
|
||||
printf '%s\n' "${invalid_scope}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
not_bootstrap="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
|
||||
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
|
||||
| .all[]?
|
||||
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
|
||||
| "- \(.packageName)@\(.version)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${not_bootstrap}" ]]; then
|
||||
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
|
||||
printf '%s\n' "${not_bootstrap}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
|
||||
echo "No selected packages require ClawHub bootstrap." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
|
||||
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "ClawHub bootstrap candidates:"
|
||||
jq -r '
|
||||
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
echo "ClawHub trusted-publisher repair candidates:"
|
||||
jq -r '
|
||||
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
invalid="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
|
||||
| select(.publishTag != "alpha" or .channel != "alpha")
|
||||
| "- \(.packageName)@\(.version) [\(.publishTag)]"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: resolve_bootstrap_plan
|
||||
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
validate_bootstrap_trusted_publisher_cli:
|
||||
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate pinned ClawHub trusted publisher CLI support
|
||||
env:
|
||||
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
help_output="$(
|
||||
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
|
||||
clawhub package trusted-publisher set --help 2>&1 || true
|
||||
)"
|
||||
printf '%s\n' "${help_output}"
|
||||
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
|
||||
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
|
||||
exit 1
|
||||
fi
|
||||
for required_flag in --repository --workflow-filename; do
|
||||
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
|
||||
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
publish_bootstrap_plugins:
|
||||
needs:
|
||||
[
|
||||
resolve_bootstrap_plan,
|
||||
validate_release_publish_approval,
|
||||
validate_bootstrap_trusted_publisher_cli,
|
||||
]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-bootstrap
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout target revision
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
git checkout --detach "${TARGET_SHA}"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Install pinned ClawHub CLI wrapper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
|
||||
EOF
|
||||
chmod +x "${RUNNER_TEMP}/clawhub"
|
||||
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Write ClawHub token config
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
config_path="${RUNNER_TEMP}/clawhub-config.json"
|
||||
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const registry = process.env.CLAWHUB_REGISTRY?.trim();
|
||||
const token = process.env.CLAWHUB_TOKEN?.trim();
|
||||
const configPath = process.env.CONFIG_PATH;
|
||||
if (!registry) {
|
||||
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
|
||||
}
|
||||
if (!configPath) {
|
||||
throw new Error("CONFIG_PATH is required.");
|
||||
}
|
||||
|
||||
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
NODE
|
||||
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Publish ClawHub bootstrap package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
|
||||
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
|
||||
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
|
||||
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
|
||||
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
|
||||
elif [[ "${DRY_RUN}" == "true" ]]; then
|
||||
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
else
|
||||
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
|
||||
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
|
||||
fi
|
||||
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
fi
|
||||
|
||||
- name: Configure trusted publisher for normal OIDC releases
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
|
||||
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
|
||||
--repository openclaw/openclaw \
|
||||
--workflow-filename plugin-clawhub-release.yml
|
||||
|
||||
verify_bootstrap_clawhub_package:
|
||||
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Verify bootstrap ClawHub package and trusted publisher
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'EOF'
|
||||
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
|
||||
const packageName = process.env.PACKAGE_NAME;
|
||||
const packageVersion = process.env.PACKAGE_VERSION;
|
||||
const packageTag = process.env.PACKAGE_TAG;
|
||||
if (!packageName || !packageVersion || !packageTag) {
|
||||
throw new Error("Missing ClawHub bootstrap verification env.");
|
||||
}
|
||||
const encodedName = encodeURIComponent(packageName);
|
||||
const encodedVersion = encodeURIComponent(packageVersion);
|
||||
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
|
||||
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
|
||||
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
|
||||
const artifactUrl = `${versionUrl}/artifact/download`;
|
||||
|
||||
async function fetchWithRetry(url, options = {}) {
|
||||
let lastStatus = "unknown";
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual", ...options });
|
||||
lastStatus = response.status;
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
lastStatus = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
|
||||
}
|
||||
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
|
||||
}
|
||||
|
||||
const detailResponse = await fetchWithRetry(detailUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
|
||||
}
|
||||
const detail = await detailResponse.json();
|
||||
const tags = detail?.package?.tags ?? {};
|
||||
if (tags[packageTag] !== packageVersion) {
|
||||
throw new Error(
|
||||
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!trustedPublisherResponse.ok) {
|
||||
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
|
||||
}
|
||||
const trustedPublisherDetail = await trustedPublisherResponse.json();
|
||||
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
|
||||
if (
|
||||
trustedPublisher?.repository !== "openclaw/openclaw" ||
|
||||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
|
||||
trustedPublisher?.environment != null
|
||||
) {
|
||||
throw new Error(
|
||||
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionResponse = await fetchWithRetry(versionUrl);
|
||||
if (!versionResponse.ok) {
|
||||
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
|
||||
}
|
||||
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
|
||||
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
|
||||
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
|
||||
}
|
||||
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
|
||||
EOF
|
||||
258
.github/workflows/plugin-clawhub-release.yml
vendored
258
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -16,7 +16,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -24,10 +24,6 @@ on:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch name of the approving OpenClaw Release Publish workflow run
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the full ClawHub artifact handoff without publishing.
|
||||
required: false
|
||||
@@ -42,7 +38,9 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
@@ -52,15 +50,9 @@ jobs:
|
||||
outputs:
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
|
||||
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
|
||||
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
|
||||
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
|
||||
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -91,27 +83,9 @@ jobs:
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate OIDC source matches workflow ref
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.ref.outputs.sha }}
|
||||
WORKFLOW_SHA: ${{ github.sha }}
|
||||
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
|
||||
exit 0
|
||||
fi
|
||||
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
|
||||
echo "The ref input is only supported for dry_run=true." >&2
|
||||
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
@@ -122,8 +96,8 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
@@ -184,78 +158,36 @@ jobs:
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
|
||||
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
has_bootstrap_candidates="false"
|
||||
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
|
||||
has_bootstrap_candidates="true"
|
||||
fi
|
||||
has_missing_trusted_publisher="false"
|
||||
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
|
||||
has_missing_trusted_publisher="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
|
||||
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
|
||||
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
|
||||
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
|
||||
echo "skipped_published_count=${skipped_published_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
|
||||
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
|
||||
echo "matrix=${matrix_json}"
|
||||
echo "bootstrap_matrix=${bootstrap_matrix_json}"
|
||||
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Bootstrap candidates requiring token bootstrap:"
|
||||
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Missing trusted publisher candidates:"
|
||||
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Fail when trusted publisher is missing
|
||||
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
|
||||
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
|
||||
exit 1
|
||||
|
||||
- name: Fail normal publish when bootstrap is required
|
||||
if: steps.plan.outputs.bootstrap_candidate_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
|
||||
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
|
||||
exit 1
|
||||
|
||||
- name: Fail manual publish when target versions already exist
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
|
||||
exit 1
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
@@ -265,6 +197,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify OpenClaw ClawHub package ownership
|
||||
if: steps.plan.outputs.has_candidates == 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_clawhub
|
||||
@@ -283,7 +221,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
@@ -302,8 +240,99 @@ jobs:
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout target revision
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
git checkout --detach "${TARGET_SHA}"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview publish command
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
pack_plugins_clawhub_artifacts:
|
||||
needs: [preview_plugins_clawhub, validate_release_publish_approval]
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -338,19 +367,47 @@ jobs:
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pinned ClawHub CLI wrapper
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
EOF
|
||||
chmod +x "${RUNNER_TEMP}/clawhub"
|
||||
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Pack ClawHub package artifact
|
||||
env:
|
||||
@@ -371,23 +428,19 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
approve_plugins_clawhub_release:
|
||||
approve_plugin_clawhub_release:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
contents: read
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Approve Plugin ClawHub release publish
|
||||
run: |
|
||||
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
|
||||
- name: Approve ClawHub package publish
|
||||
run: echo "ClawHub package publish approved."
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs:
|
||||
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -397,18 +450,19 @@ jobs:
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
|
||||
with:
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
json: true
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
registry: https://clawhub.ai
|
||||
site: https://clawhub.ai
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
source_repo: ${{ github.repository }}
|
||||
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
source_ref: ${{ github.ref }}
|
||||
source_path: ${{ matrix.plugin.packageDir }}
|
||||
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
|
||||
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
|
||||
verify_published_clawhub_package:
|
||||
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
|
||||
|
||||
1
.github/workflows/plugin-npm-release.yml
vendored
1
.github/workflows/plugin-npm-release.yml
vendored
@@ -288,7 +288,6 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Verify published runtime
|
||||
|
||||
32
.github/workflows/qa-live-transports-convex.yml
vendored
32
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -159,7 +159,7 @@ jobs:
|
||||
run_mock_parity:
|
||||
name: Run QA Lab mock parity lane
|
||||
needs: [validate_selected_ref]
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
name: Run live runtime token-efficiency lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: github.event_name == 'schedule'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
environment: qa-live-shared
|
||||
env:
|
||||
@@ -267,7 +267,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run live runtime parity lane
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
name: Run Matrix live QA lane
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all') }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane
|
||||
@@ -397,7 +397,7 @@ jobs:
|
||||
name: Run Matrix live QA lane (${{ matrix.profile }})
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.matrix_profile == 'all' }}
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
strategy:
|
||||
@@ -437,7 +437,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Matrix live lane shard
|
||||
@@ -480,7 +480,7 @@ jobs:
|
||||
run_live_telegram:
|
||||
name: Run Telegram live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -520,7 +520,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Telegram live lane
|
||||
@@ -575,7 +575,7 @@ jobs:
|
||||
run_live_discord:
|
||||
name: Run Discord live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -615,7 +615,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Discord live lane
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
run_live_whatsapp:
|
||||
name: Run WhatsApp live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
concurrency:
|
||||
group: qa-live-whatsapp-shared
|
||||
@@ -712,7 +712,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run WhatsApp live lane
|
||||
@@ -766,7 +766,7 @@ jobs:
|
||||
run_live_slack:
|
||||
name: Run Slack live QA lane with Convex leases
|
||||
needs: [authorize_actor, validate_selected_ref]
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
steps:
|
||||
@@ -806,7 +806,7 @@ jobs:
|
||||
|
||||
- name: Build private QA runtime
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=12288
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Slack live lane
|
||||
|
||||
95
.github/workflows/stale.yml
vendored
95
.github/workflows/stale.yml
vendored
@@ -509,62 +509,60 @@ jobs:
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
let cursor = null;
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const result = await github.graphql(
|
||||
`query ClosedIssuesForLocking(
|
||||
$owner: String!
|
||||
$repo: String!
|
||||
$cursor: String
|
||||
$perPage: Int!
|
||||
) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(
|
||||
first: $perPage
|
||||
after: $cursor
|
||||
states: CLOSED
|
||||
orderBy: { field: CREATED_AT, direction: ASC }
|
||||
) {
|
||||
nodes {
|
||||
number
|
||||
locked
|
||||
closedAt
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
cursor,
|
||||
perPage,
|
||||
},
|
||||
);
|
||||
const issues = result.repository.issues;
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
|
||||
for (const issue of issues.nodes) {
|
||||
if (issue.locked || !issue.closedAt) {
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
if (issue.locked) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.closed_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closedAt);
|
||||
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs)) {
|
||||
continue;
|
||||
}
|
||||
if (closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastComment = issue.comments.nodes[0];
|
||||
const lastCommentMs = lastComment ? Date.parse(lastComment.createdAt) : 0;
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
|
||||
if (lastActivityMs > cutoffMs) {
|
||||
continue;
|
||||
@@ -580,10 +578,7 @@ jobs:
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
if (!issues.pageInfo.hasNextPage || !issues.pageInfo.endCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = issues.pageInfo.endCursor;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
|
||||
@@ -251,11 +251,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Lockfiles/shrinkwrap are security surface: review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, `package-lock.json`; root/plugin npm packages ship shrinkwrap, not package-lock.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Use `$release-openclaw-maintainer`.
|
||||
- Release versions use `YYYY.M.PATCH`, where `PATCH` is a sequential monthly release-train number, never the calendar day. Stable and beta tags determine the current train; alpha-only tags do not consume or advance the beta/stable patch number. After `2026.6.5`, the next beta train is `2026.6.6-beta.1` even if higher alpha-only tags exist.
|
||||
- Alpha/nightly versions use the next unreleased train plus an incrementing prerelease number. Repeated nightlies for the same train increment only `alpha.N`; they must not mint a new patch number from the date.
|
||||
- Backport means apply to newest open `release/` branch unless user names another target.
|
||||
- GHSA/advisories: `$openclaw-ghsa-maintainer` / `$security-triage`. Secret scanning: `$openclaw-secret-scanning-maintainer`.
|
||||
- Beta tag/version match: `vYYYY.M.PATCH-beta.N` -> npm `YYYY.M.PATCH-beta.N --tag beta`.
|
||||
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
|
||||
|
||||
## Platform / Ops
|
||||
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,38 +2,17 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.6.6
|
||||
|
||||
### Highlights
|
||||
|
||||
- Security boundaries are substantially tighter across transcripts, sandbox binds, host environment inheritance, MCP stdio, Codex HTTP access, native search policy, elevated sender checks, deleted-agent ACP bypasses, loopback tools, Discord moderation, and Teams group actions; exec approvals now fail closed on timeout. (#91529, #91618, #91615, #91619, #91741, #91745, #91746, #91748, #91749, #91750, #91751, #91752, #91763, #89938) Thanks @joshavant, @pgondhi987, @mmaps, @eleqtrizit, @shakkernerd, and @drobison00.
|
||||
- Telegram delivery is safer and more coherent: account-scoped topics route to the right agent, streamed text survives tool calls, `/compact` works on generic ingress, callback handling uses concrete APIs, draft chunking is shared, durable dispatch dedupe moved into the SDK, and unauthorized DM text stays out of cache and prompt context. (#91189, #88682, #89588, #90212, #91876, #91874, #91904, #91478, #91915) Thanks @codysai001, @alexzhu0, @joelnishanth, @snowzlm, @obviyus, and @sallyom.
|
||||
- iMessage recovery and delivery now cover always-on inbound restart, durable echo markers, block streaming, idle approval discovery, hardened outbound transport, and actionable inbound startup diagnostics. (#91335, #91449, #88969, #88530, #91783, #91785) Thanks @omarshahine, @jmissig, and @colmbrogan.
|
||||
- Browser and MCP connectivity gained existing-session CDP support, discovered WebSocket validation, default-profile `cdpUrl` handling, safer browser-output boundaries, Streamable HTTP loopback transport, corrected OAuth/SSE authorization handling, and broader schema compatibility. (#91422, #89851, #91736, #91747, #91451, #80143) Thanks @pgondhi987, @anagnorisis2peripeteia, @lifuyue, @eleqtrizit, @LiuwqGit, and @HemantSudarshan.
|
||||
- Control UI startup and first-reply latency are lower through cached model metadata, removal of the startup catalog wait, lazy slash-command loading, and first-event tracing with slow-reply diagnostics. (#91531, #91538, #91568, #91583, #91598)
|
||||
- Provider support expands with OpenRouter OAuth onboarding and Claude Fable 5 adaptive thinking, while Codex sessions keep correct compaction ownership, local models skip guardian review, dynamic tool progress normalizes cleanly, and Gemma 4 reasoning replay is preserved. (#91830, #91882, #91590, #88630, #88768, #91696) Thanks @Patrick-Erichsen, @joshavant, @bdjben, and @Coder-Wangyankun.
|
||||
|
||||
### Changes
|
||||
|
||||
- CLI progress: emit Claude CLI commentary progress events and bridge inter-tool commentary into channel progress without exposing internal protocol scaffolding. (#89834, #90883) Thanks @anagnorisis2peripeteia.
|
||||
- Observability: allow trusted diagnostics channels to capture tool input/output content, add first-assistant-event traces, and warn on slow initial replies. (#91256, #91568, #91583) Thanks @amknight.
|
||||
- Plugins/ClawHub: dogfood reusable package publishing, let dry runs skip publish approval, allow declared installed trusted hooks, report managed plugin version drift, and warn instead of failing on retired Skill Workshop configuration. (#91574, #91591, #90004, #90927, #90838) Thanks @Patrick-Erichsen, @brokemac79, and @lonexreb.
|
||||
- Memory/providers: move the local llama.cpp runtime into its provider plugin, batch embeddings across files, persist the agent model catalog cache, and keep QMD JSON search one-shot while filtering stale REM recall previews. (#91324, #89138, #90457, #91837, #91851) Thanks @osolmaz, @mushuiyu886, @ai-hpc, and @TurboTheTurtle.
|
||||
- Channels/mobile: add the QQBot group mention toggle, improve iPad and iPhone control surfaces, and expose the active connection host in the TUI footer. (#91423, #91557, #89909) Thanks @cxyhhhhh, @Solvely-Colin, and @baskduf.
|
||||
- Performance: prewarm TUI runtime plugins, deduplicate plugin auto-enable fanout, trim dense text-delta snapshots, and reuse prepared startup model metadata. (#90782, #89978, #91580, #91531) Thanks @RomneyDa and @ai-hpc.
|
||||
## 2026.6.9-alpha.3
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agent/session recovery: drop stale approval follow-ups after session rebind, remove drained reply-queue items by identity, recover stale main and visible replies, preserve Codex context-engine compaction ownership, lower the default compaction timeout to 180 seconds while respecting explicit configuration, and keep provider-failure terminal lifecycle state correct. (#85679, #91450, #91566, #91840, #91590, #91361, #91895) Thanks @openperf, @yetval, @joshavant, @wangmiao0668000666, and @TurboTheTurtle.
|
||||
- User-visible content boundaries: suppress Codex/Harmony protocol artifacts, neutralize browser and LanceDB memory media directives, redact transcript images, and preserve native `/compact` replies through source suppression. (#89151, #91422, #91425, #91529, #90212) Thanks @joelnishanth, @pgondhi987, @joshavant, and @snowzlm.
|
||||
- Channel delivery: keep WhatsApp captured replies attached to the successor controller after restart, retry Feishu rate limits, preserve Mattermost thread replies, canonicalize LINE webhook paths, restore Discord reply hydration and runtime timeout exports, and show OpenAI Realtime WebRTC assistant transcripts. (#85823, #89659, #91684, #91649, #90263, #91686, #90426) Thanks @itsuzef, @ladygege, @jacobtomlinson, @fuller-stack-dev, and @shushushv.
|
||||
- Cron: cancel active task runs cleanly, preserve terminal timeout/cancel state, and recover no-deliver tool warnings instead of silently losing the outcome. (#90666, #90678) Thanks @ai-hpc.
|
||||
- Gateway/config/auth: share the approval runtime socket token, replace arrays explicitly in `config.patch`, skip the deleted-agent guard only for valid ACP harness sessions, surface headless LaunchAgent state, verify SQLite auth migration before cleanup, and arm QMD startup maintenance. (#87105, #91551, #91219, #91614, #91740, #91978) Thanks @fuller-stack-dev and @scotthuang.
|
||||
- Providers/Codex: clarify quota errors, restore the Codex synthetic usage line, canonicalize Codex protocol assets, require API-key auth for realtime voice, normalize ACP model refs, preserve Gemma 4 `reasoning_content`, and avoid guardian review for local models. (#91390, #91709, #91507, #91567, #88630, #91696) Thanks @hxy91819, @brokemac79, @RomneyDa, @joshavant, and @Coder-Wangyankun.
|
||||
- Updates/builds: recover package Gateway restarts after refresh failure, expose plugin convergence repair, fall back to Corepack in PATH-less pnpm environments, seed the correct Docker store packages, and keep ClawHub dry-run and publish paths reusable. (#91581, #91599, #91547, #91591) Thanks @fuller-stack-dev, @sallyom, and @Patrick-Erichsen.
|
||||
- UI: require explicit user intent before opening chat sessions and drain restored chat queues after session switches. (#91480) Thanks @TurboTheTurtle.
|
||||
- Android: avoid the `dataSync` foreground-service type for persistent nodes. (#80082) Thanks @davelutztx.
|
||||
- Native hooks: bound relay lifetimes so abandoned native hook connections cannot linger indefinitely. (#91550) Thanks @joshavant.
|
||||
- Alpha/nightly package acceptance refreshes migrated SQLite session metadata when legacy transcript files move during doctor preflight, keeping upgrade-survivor package checks aligned with the migrated session store.
|
||||
|
||||
## 2026.6.9-alpha.2
|
||||
|
||||
### Fixes
|
||||
|
||||
- Alpha/nightly release validation carries release-branch stabilization for package metadata, channel and plugin fixture isolation, Docker and QA waits, iMessage monitor retry tests, memory startup catch-up test coverage, and release prepack changelog packaging.
|
||||
|
||||
## 2026.6.5
|
||||
|
||||
@@ -64,7 +43,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s.
|
||||
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
|
||||
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
|
||||
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.
|
||||
@@ -79,7 +57,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.
|
||||
- Release/CI/E2E: plugin lifecycle matrix resource sampling now fails phases that exceed RSS, wall-clock, or CPU ceilings instead of only logging the measurements.
|
||||
- Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.
|
||||
- Memory: keep doctor REM harness previews aligned with live REM by dropping short-term recall snippets whose source files disappeared before rendering preview output. Thanks @samzong and @frankekn.
|
||||
- Tests/state isolation: QA Lab valid-tool-call metrics now require runtime tool-call evidence when runtime parity data is available instead of counting tool-backed scenario pass status alone.
|
||||
- Tests/state isolation: QA Lab runtime parity now fails planned-only tool-call rows without matching tool results instead of treating matching mock plans as real tool evidence.
|
||||
- Tests/state isolation: provider, media, auth, cron, task, session, sandbox, Gateway, and Codex timeout fixtures now scope more home/state/env data per test, reducing cross-test leakage and making release validation failures less noisy. (#90027, #89974)
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -116,19 +116,11 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
|
||||
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
|
||||
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
|
||||
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
|
||||
fi && \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
|
||||
mkdir -p dist/extensions/qa-lab/web && \
|
||||
rm -rf dist/extensions/qa-lab/web/dist && \
|
||||
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
|
||||
fi
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies, omitted plugin runtime packages, and build-only
|
||||
# metadata before copying runtime assets into the final image.
|
||||
|
||||
@@ -48,7 +48,6 @@ These patterns are usually not vulnerabilities by themselves:
|
||||
|
||||
- Prompt injection without a policy, auth, approval, sandbox, or tool-boundary bypass.
|
||||
- A trusted operator using an intentional local feature, such as local shell access or browser/script execution.
|
||||
- A report whose only primitive is changing the process or child-process environment before running OpenClaw or an executable OpenClaw invokes.
|
||||
- A malicious plugin after a trusted operator installs or enables it.
|
||||
- Multiple adversarial users sharing one Gateway host/config and expecting per-user isolation.
|
||||
- Scanner-only, dependency-only, or stale-path reports without a working repro and demonstrated OpenClaw impact.
|
||||
@@ -104,7 +103,6 @@ These are frequently reported but are typically closed with no code change:
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on attacker-controlled environment variables changing executable behavior, including variables that redirect lookup paths, preload code, select wrappers/interpreters, alter package-manager or runtime hooks, or make one executable call another executable. Control of the process or child-process environment is trusted host/operator control in OpenClaw's model; these reports need a separate OpenClaw boundary bypass that lets untrusted input set or mutate that environment.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Reports against test-only harnesses, QA Lab, QE Lab, E2E fixtures, benchmark rigs, or maintainer-only debugging tools when the vulnerable code is not shipped as a supported production surface.
|
||||
@@ -163,7 +161,6 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports whose only claim is environment-variable-driven executable behavior change, including path lookup changes, preload hooks, wrapper/interpreter selection, package-manager/runtime hooks, or variables that make an executable invoke another executable, unless a separate OpenClaw boundary bypass lets untrusted input set or mutate that environment.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports whose only claim is use of an explicit trusted-operator control surface (for example `canvas.eval`, browser evaluate/script execution, or direct `node.invoke` execution) without demonstrating an auth, policy, allowlist, approval, or sandbox bypass.
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
@@ -184,7 +181,6 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
OpenClaw security guidance assumes:
|
||||
|
||||
- The host where OpenClaw runs is within a trusted OS/admin boundary.
|
||||
- Anyone who can set or mutate the OpenClaw process environment, launcher environment, or child-process environment is inside that trusted host/operator boundary.
|
||||
- Anyone who can modify `~/.openclaw` state/config (including `openclaw.json`) is effectively a trusted operator.
|
||||
- A single Gateway shared by mutually untrusted people is **not a recommended setup**. Use separate gateways (or at minimum separate OS users/hosts) per trust boundary.
|
||||
- Authenticated Gateway callers are treated as trusted operators. Session identifiers (for example `sessionKey`) are routing controls, not per-user authorization boundaries.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB |
File diff suppressed because one or more lines are too long
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
|
||||
struct AgentProDreamingDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let overviewLoading: Bool
|
||||
@@ -21,7 +20,6 @@ struct AgentProDreamingDestination: View {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.detailSummaryCard(
|
||||
icon: "moon",
|
||||
title: "Dreaming",
|
||||
@@ -59,23 +57,6 @@ struct AgentProDreamingDestination: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Dreaming",
|
||||
subtitle: self.dreamingDetail,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DreamAction: String, CaseIterable, Identifiable {
|
||||
case backfill
|
||||
case repair
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AgentProNodesDestination: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let overview: AgentOverviewSnapshot?
|
||||
let gatewayConnected: Bool
|
||||
let agentCount: Int
|
||||
@@ -17,7 +16,6 @@ struct AgentProNodesDestination: View {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.header
|
||||
self.summaryCard
|
||||
self.totalsCard
|
||||
self.nodesList
|
||||
@@ -29,33 +27,16 @@ struct AgentProNodesDestination: View {
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Instances")
|
||||
.navigationTitle("Nodes")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
if let headerLeadingAction {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Instances",
|
||||
subtitle: self.instancesDetail,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "display", color: self.instancesColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Instances")
|
||||
Text("Nodes")
|
||||
.font(.headline)
|
||||
Text(self.instancesDetail)
|
||||
.font(.caption)
|
||||
@@ -89,16 +70,16 @@ struct AgentProNodesDestination: View {
|
||||
|
||||
private var nodesList: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ProSectionHeader(title: "Connected Instances")
|
||||
ProSectionHeader(title: "Connected Nodes")
|
||||
ProCard(padding: 0) {
|
||||
let nodes = self.sortedPresenceEntries
|
||||
if nodes.isEmpty {
|
||||
self.emptyRow(
|
||||
icon: "display",
|
||||
title: self.gatewayConnected ? "No instances connected" : "Instances unavailable",
|
||||
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 instances.")
|
||||
: "Connect a gateway to inspect connected nodes.")
|
||||
.padding(14)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
@@ -133,7 +114,7 @@ struct AgentProNodesDestination: View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(Self.presenceDetail(entry))
|
||||
@@ -172,7 +153,7 @@ struct AgentProNodesDestination: View {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: Self.presenceIcon(entry), color: Self.presenceColor(entry))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(Self.presenceLabel(entry) ?? "Instance")
|
||||
Text(Self.presenceLabel(entry) ?? "Node")
|
||||
.font(.headline)
|
||||
Text(Self.presenceDetail(entry))
|
||||
.font(.caption)
|
||||
@@ -211,7 +192,7 @@ struct AgentProNodesDestination: View {
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Instance")
|
||||
.navigationTitle(Self.presenceLabel(entry) ?? "Node")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,10 @@ extension AgentProTab {
|
||||
@ViewBuilder
|
||||
func destination(for route: AgentRoute) -> some View {
|
||||
switch route {
|
||||
case .agents:
|
||||
self.agentsDestination
|
||||
case .skills:
|
||||
self.skillsDestination
|
||||
case .instances:
|
||||
self.instancesDestination
|
||||
case .nodes:
|
||||
self.nodesDestination
|
||||
case .cron:
|
||||
self.cronDestination
|
||||
case .usage:
|
||||
@@ -21,26 +19,6 @@ extension AgentProTab {
|
||||
}
|
||||
}
|
||||
|
||||
var agentsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.rosterHeader
|
||||
self.agentFilters
|
||||
self.agentsSection
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshOverview(force: true)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Agents")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var skillsDestination: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
@@ -68,9 +46,8 @@ extension AgentProTab {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
var instancesDestination: some View {
|
||||
var nodesDestination: some View {
|
||||
AgentProNodesDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .instances),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
agentCount: self.appModel.gatewayAgents.count,
|
||||
@@ -87,10 +64,6 @@ extension AgentProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .cron,
|
||||
title: "Cron Jobs",
|
||||
subtitle: self.cronDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron Jobs",
|
||||
@@ -116,10 +89,6 @@ extension AgentProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.directHeader(
|
||||
for: .usage,
|
||||
title: "Usage",
|
||||
subtitle: self.usageDetail)
|
||||
self.detailSummaryCard(
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
title: "Usage",
|
||||
@@ -142,7 +111,6 @@ extension AgentProTab {
|
||||
|
||||
var dreamingDestination: some View {
|
||||
AgentProDreamingDestination(
|
||||
headerLeadingAction: self.directHeaderLeadingAction(for: .dreaming),
|
||||
overview: self.overview,
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
overviewLoading: self.overviewLoading,
|
||||
@@ -154,27 +122,6 @@ extension AgentProTab {
|
||||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func directHeader(for route: AgentRoute, title: String, subtitle: String) -> some View {
|
||||
if let headerLeadingAction = self.directHeaderLeadingAction(for: route) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
func directHeaderLeadingAction(for route: AgentRoute) -> OpenClawSidebarHeaderAction? {
|
||||
self.directRoute == route ? self.headerLeadingAction : nil
|
||||
}
|
||||
|
||||
func detailSummaryCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
|
||||
@@ -5,19 +5,18 @@ import SwiftUI
|
||||
extension AgentProTab {
|
||||
var rosterHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: "\(self.sortedAgents.count) total",
|
||||
titleFont: .system(size: 28, weight: .bold),
|
||||
subtitleFont: .subheadline,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
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)
|
||||
}
|
||||
} accessory: {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
self.gatewayPillButton
|
||||
self.headerIconButton(
|
||||
systemName: "magnifyingglass",
|
||||
label: "Search agents",
|
||||
@@ -57,19 +56,6 @@ extension AgentProTab {
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
var agentFilters: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
@@ -154,7 +140,7 @@ extension AgentProTab {
|
||||
value: self.instancesValue,
|
||||
detail: self.instancesDetail,
|
||||
color: self.instancesColor,
|
||||
route: .instances)
|
||||
route: .nodes)
|
||||
self.metricTile(
|
||||
icon: "clock.arrow.circlepath",
|
||||
title: "Cron",
|
||||
|
||||
@@ -6,12 +6,6 @@ struct AgentProTab: View {
|
||||
@Environment(NodeAppModel.self) var appModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
let initialRoute: AgentRoute?
|
||||
let directRoute: AgentRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String
|
||||
let openSettings: (() -> Void)?
|
||||
@State var navigationPath: [AgentRoute] = []
|
||||
@State var overview: AgentOverviewSnapshot?
|
||||
@State var overviewErrorText: String?
|
||||
@State var overviewLoading: Bool = false
|
||||
@@ -37,9 +31,8 @@ struct AgentProTab: View {
|
||||
@State var cronActionStatusText: String?
|
||||
|
||||
enum AgentRoute: Hashable {
|
||||
case agents
|
||||
case skills
|
||||
case instances
|
||||
case nodes
|
||||
case cron
|
||||
case usage
|
||||
case dreaming
|
||||
@@ -126,42 +119,8 @@ struct AgentProTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
initialRoute: AgentRoute? = nil,
|
||||
directRoute: AgentRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String = "Agents",
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let directRoute {
|
||||
self.directDestination(for: directRoute)
|
||||
} else {
|
||||
self.overviewNavigation
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var overviewNavigation: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
@@ -184,22 +143,15 @@ struct AgentProTab: View {
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialRouteIfNeeded()
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func directDestination(for route: AgentRoute) -> some View {
|
||||
self.destination(for: route)
|
||||
.toolbar(
|
||||
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
|
||||
for: .navigationBar)
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,25 +7,6 @@ struct ChatProTab: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel: OpenClawChatViewModel?
|
||||
@State private var viewModelUsesAppleReviewDemoTransport = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let headerTitle: String?
|
||||
let headerSubtitle: String?
|
||||
let showsAgentBadge: Bool
|
||||
let openSettings: (() -> Void)?
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
headerTitle: String? = nil,
|
||||
headerSubtitle: String? = nil,
|
||||
showsAgentBadge: Bool = true,
|
||||
openSettings: (() -> Void)? = nil)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.headerTitle = headerTitle
|
||||
self.headerSubtitle = headerSubtitle
|
||||
self.showsAgentBadge = showsAgentBadge
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -86,30 +67,7 @@ struct ChatProTab: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerDisplayTitle,
|
||||
subtitle: self.headerDisplaySubtitle,
|
||||
titleFont: .headline.weight(.semibold),
|
||||
subtitleFont: .caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
HStack(spacing: 11) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
self.headerIdentityBadge
|
||||
}
|
||||
} accessory: {
|
||||
self.connectionPillButton
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerIdentityBadge: some View {
|
||||
if self.showsAgentBadge {
|
||||
HStack(spacing: 11) {
|
||||
Text(self.agentBadge)
|
||||
.font(.system(size: self.agentBadge.count > 2 ? 13 : 16, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
@@ -128,9 +86,24 @@ struct ChatProTab: View {
|
||||
endPoint: .bottomTrailing)))
|
||||
.overlay(Circle().strokeBorder(.white.opacity(0.18), lineWidth: 1))
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: 10, y: 5)
|
||||
} else {
|
||||
ProIconBadge(systemName: "bubble.left", color: OpenClawBrand.accent)
|
||||
|
||||
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() {
|
||||
@@ -189,93 +162,37 @@ struct ChatProTab: View {
|
||||
?? "main"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectionPillButton: some View {
|
||||
if let openSettings {
|
||||
Button(action: openSettings) {
|
||||
self.connectionPill
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
self.connectionPill
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionPill: some View {
|
||||
HStack(spacing: 6) {
|
||||
ProStatusDot(color: self.gatewayPillColor)
|
||||
Text(Self.gatewayPillTitle(state: self.gatewayDisplayState, isGatewayUsable: self.gatewayConnected))
|
||||
ProStatusDot(color: self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
Text(self.gatewayConnected ? "Connected" : "Connecting")
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(self.gatewayPillColor)
|
||||
.foregroundStyle(self.gatewayConnected ? OpenClawBrand.ok : .orange)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(self.gatewayPillColor.opacity(0.11))
|
||||
.fill((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.11))
|
||||
}
|
||||
.overlay {
|
||||
Capsule()
|
||||
.strokeBorder(self.gatewayPillColor.opacity(0.16), lineWidth: 1)
|
||||
.strokeBorder((self.gatewayConnected ? OpenClawBrand.ok : Color.orange).opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
guard self.gatewayDisplayState == .connected else {
|
||||
guard GatewayStatusBuilder.build(appModel: self.appModel) == .connected else {
|
||||
return false
|
||||
}
|
||||
return self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var gatewayDisplayState: GatewayDisplayState {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel)
|
||||
}
|
||||
|
||||
private var gatewayPillColor: Color {
|
||||
switch self.gatewayDisplayState {
|
||||
case .connected:
|
||||
self.gatewayConnected ? OpenClawBrand.ok : .secondary
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func gatewayPillTitle(state: GatewayDisplayState, isGatewayUsable: Bool) -> String {
|
||||
switch state {
|
||||
case .connected:
|
||||
isGatewayUsable ? "Connected" : "Unavailable"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var messagePlaceholder: String {
|
||||
self.gatewayConnected ? "Message \(self.agentDisplayName)..." : "Connect to a gateway"
|
||||
}
|
||||
|
||||
private var headerDisplayTitle: String {
|
||||
self.normalized(self.headerTitle)
|
||||
?? Self.defaultHeaderTitle(showsAgentBadge: self.showsAgentBadge, agentDisplayName: self.agentDisplayName)
|
||||
}
|
||||
|
||||
private var headerDisplaySubtitle: String {
|
||||
self.normalized(self.headerSubtitle) ?? "AI Assistant"
|
||||
}
|
||||
|
||||
nonisolated static func defaultHeaderTitle(showsAgentBadge: Bool, agentDisplayName: String) -> String {
|
||||
showsAgentBadge ? agentDisplayName : "Chat"
|
||||
}
|
||||
|
||||
private var chatUserAccent: Color {
|
||||
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct CommandPanel<Content: View>: View {
|
||||
tint: self.tint,
|
||||
isProminent: self.isProminent,
|
||||
padding: self.padding,
|
||||
radius: OpenClawProMetric.cardRadius)
|
||||
radius: 12)
|
||||
{
|
||||
self.content
|
||||
}
|
||||
@@ -34,15 +34,40 @@ struct CommandControlBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
LinearGradient(
|
||||
colors: self.colorScheme == .dark ? self.darkColors : self.lightColors,
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
Color.white.opacity(0.20)
|
||||
.frame(height: 140)
|
||||
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 {
|
||||
@@ -89,12 +114,12 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
@@ -111,11 +136,11 @@ struct CommandSessionRow: View {
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,21 +154,21 @@ struct CommandViewMoreRow: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.rowFill)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(self.rowBorder, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var rowFill: Color {
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color(uiColor: .systemBackground)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.035) : Color.black.opacity(0.025)
|
||||
}
|
||||
|
||||
private var rowBorder: Color {
|
||||
Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.24 : 0.22)
|
||||
self.colorScheme == .dark ? Color.white.opacity(0.065) : Color.black.opacity(0.045)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,13 +199,13 @@ struct CommandEmptyStateRow: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.vertical, 9)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.fill(Color(uiColor: .systemBackground))
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.black.opacity(0.06))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OpenClawProMetric.controlRadius, style: .continuous)
|
||||
.strokeBorder(Color(uiColor: .separator).opacity(0.22), lineWidth: 1)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(0.055), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,13 @@ import OpenClawChatUI
|
||||
import SwiftUI
|
||||
|
||||
struct CommandCenterTab: View {
|
||||
static let recentSessionsFetchLimit = 200
|
||||
fileprivate static let recentSessionsFetchLimit = 200
|
||||
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var defaultChatSessionEntry: OpenClawChatSessionEntry?
|
||||
@State private var recentChatSessions: [OpenClawChatSessionEntry] = []
|
||||
var headerTitle: String = "OpenClaw"
|
||||
var headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var showsHeaderMark: Bool = true
|
||||
var openChat: () -> Void
|
||||
var openSettings: () -> Void
|
||||
|
||||
@@ -35,37 +31,20 @@ struct CommandCenterTab: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
if Self.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: self.horizontalSizeClass,
|
||||
containerWidth: geometry.size.width)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.defaultChatSessionSection
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
self.recentSessions
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
self.defaultChatSessionSection
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.recentSessions
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, 18)
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
self.commandAmbientOverlay
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
self.gatewayCard
|
||||
self.defaultChatSessionSection
|
||||
self.recentSessions
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
@@ -74,47 +53,12 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
static func usesSplitSectionsLayout(
|
||||
horizontalSizeClass: UserInterfaceSizeClass?,
|
||||
containerWidth: CGFloat) -> Bool
|
||||
{
|
||||
guard horizontalSizeClass == .regular else { return false }
|
||||
return containerWidth >= 1000
|
||||
}
|
||||
|
||||
static func shouldShowHeaderMark(
|
||||
hasLeadingAction: Bool,
|
||||
showsHeaderMark: Bool) -> Bool
|
||||
{
|
||||
!hasLeadingAction && showsHeaderMark
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.headerTitle,
|
||||
subtitle: self.gatewaySubtitle,
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .caption,
|
||||
subtitleLineLimit: 1)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
} else if Self.shouldShowHeaderMark(
|
||||
hasLeadingAction: headerLeadingAction != nil,
|
||||
showsHeaderMark: self.showsHeaderMark)
|
||||
{
|
||||
OpenClawProMark(size: 28, shadowRadius: 5)
|
||||
}
|
||||
} accessory: {
|
||||
Button(action: self.openSettings) {
|
||||
ProCapsule(
|
||||
title: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
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)
|
||||
}
|
||||
@@ -142,7 +86,7 @@ struct CommandCenterTab: View {
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayStatusColor,
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash")
|
||||
icon: self.gatewayConnected ? "hourglass" : "wifi.slash")
|
||||
|
||||
HStack(spacing: 0) {
|
||||
self.gatewayFact(
|
||||
@@ -216,6 +160,7 @@ struct CommandCenterTab: View {
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var recentSessions: some View {
|
||||
@@ -255,6 +200,7 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func cardHeader(
|
||||
@@ -267,8 +213,7 @@ struct CommandCenterTab: View {
|
||||
{
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.subheadline.weight(.bold))
|
||||
if let badgeValue {
|
||||
Text(badgeValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
@@ -458,7 +403,7 @@ struct CommandCenterTab: View {
|
||||
return result
|
||||
}
|
||||
|
||||
static func sessionWorkItem(
|
||||
fileprivate static func sessionWorkItem(
|
||||
for session: OpenClawChatSessionEntry,
|
||||
currentSessionKey: String) -> WorkItem
|
||||
{
|
||||
@@ -613,20 +558,14 @@ struct CommandCenterTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandSessionsScreen: View {
|
||||
private struct CommandSessionsScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let openChat: () -> Void
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, openChat: @escaping () -> Void) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openChat = openChat
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CommandControlBackground()
|
||||
@@ -648,18 +587,12 @@ struct CommandSessionsScreen: View {
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sessions")
|
||||
.font(.system(size: 27, weight: .bold, design: .rounded))
|
||||
Text(self.headerDetail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct IPadActivityScreen: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var sessions: [OpenClawChatSessionEntry] = []
|
||||
@State private var isLoading = false
|
||||
@State private var loadErrorText: String?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let openChat: () -> Void
|
||||
let openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
openChat: @escaping () -> Void,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openChat = openChat
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
IPadSidebarScreenChrome(
|
||||
title: "Activity",
|
||||
subtitle: "Live device and gateway activity.",
|
||||
headerLeadingAction: self.headerLeadingAction,
|
||||
gatewayAction: self.openSettings)
|
||||
{
|
||||
ProMetricGrid(metrics: self.metrics)
|
||||
self.activityFeed
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
.refreshable {
|
||||
await self.refreshSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var metrics: [ProMetric] {
|
||||
[
|
||||
ProMetric(
|
||||
icon: self.gatewayConnected ? "checkmark.circle.fill" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
value: self.gatewayStateText,
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary),
|
||||
ProMetric(
|
||||
icon: "person.2.fill",
|
||||
title: "Agents",
|
||||
value: self.gatewayConnected ? "\(self.appModel.gatewayAgents.count)" : "offline",
|
||||
color: OpenClawBrand.accent),
|
||||
ProMetric(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Sessions",
|
||||
value: self.isLoading ? "..." : "\(self.sessionRows.count)",
|
||||
color: OpenClawBrand.accentHot),
|
||||
]
|
||||
}
|
||||
|
||||
private var activityFeed: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: self.isLoading ? "Loading" : nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {
|
||||
Task { await self.refreshSessions() }
|
||||
})
|
||||
|
||||
if let pendingExecApprovalPrompt = self.appModel.pendingExecApprovalPrompt {
|
||||
ProStatusRow(
|
||||
icon: "hand.raised.fill",
|
||||
title: "Approval needed",
|
||||
detail: pendingExecApprovalPrompt.commandPreview ?? pendingExecApprovalPrompt.commandText,
|
||||
value: "pending",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
|
||||
ProStatusRow(
|
||||
icon: self.gatewayConnected ? "network" : "wifi.slash",
|
||||
title: "Gateway",
|
||||
detail: self.gatewayDetailText,
|
||||
value: self.gatewayStateText.lowercased(),
|
||||
color: self.gatewayConnected ? OpenClawBrand.ok : .secondary,
|
||||
actionTitle: self.gatewayConnected ? nil : "Settings",
|
||||
action: self.gatewayConnected ? nil : self.openSettings)
|
||||
|
||||
Divider().padding(.leading, 58)
|
||||
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: self.appModel.lastShareEventText,
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
|
||||
if self.isLoading, self.sessions.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if let loadErrorText {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: loadErrorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
} else if self.sessionRows.isEmpty {
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: self.sessionsAvailable ? "No recent sessions" : "Session activity offline",
|
||||
detail: self.sessionsAvailable
|
||||
? "Start a chat and it will appear here."
|
||||
: "Connect to the gateway to load recent chat activity.",
|
||||
value: self.sessionsAvailable ? "empty" : "offline",
|
||||
color: .secondary,
|
||||
actionTitle: self.sessionsAvailable ? "Chat" : nil,
|
||||
action: self.sessionsAvailable ? self.openChat : nil)
|
||||
} else {
|
||||
ForEach(self.sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {
|
||||
self.open(row)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.sessionsMode,
|
||||
self.appModel.chatSessionKey,
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var gatewayConnected: Bool {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
guard !self.gatewayConnected else { return "Online" }
|
||||
let status = self.appModel.gatewayDisplayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return status.isEmpty ? "Offline" : status
|
||||
}
|
||||
|
||||
private var gatewayDetailText: String {
|
||||
self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.normalized(self.appModel.gatewayServerName)
|
||||
?? "No gateway connection"
|
||||
}
|
||||
|
||||
private var sessionsAvailable: Bool {
|
||||
self.appModel.isAppleReviewDemoModeEnabled || self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var sessionsMode: String {
|
||||
if self.appModel.isAppleReviewDemoModeEnabled { return "demo" }
|
||||
return self.appModel.isOperatorGatewayConnected ? "operator" : "offline"
|
||||
}
|
||||
|
||||
private var sessionRows: [CommandCenterTab.WorkItem] {
|
||||
self.sessions
|
||||
.filter { CommandCenterTab.isRecentChatSession(
|
||||
$0.key,
|
||||
defaultSessionKey: self.appModel.defaultChatSessionKey) }
|
||||
.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||
.prefix(8)
|
||||
.map {
|
||||
CommandCenterTab.sessionWorkItem(
|
||||
for: $0,
|
||||
currentSessionKey: self.appModel.chatSessionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSessions() async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.sessionsAvailable else {
|
||||
self.sessions = []
|
||||
self.loadErrorText = nil
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoading = true
|
||||
self.loadErrorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let transport: any OpenClawChatTransport = self.appModel.isAppleReviewDemoModeEnabled
|
||||
? AppleReviewDemoChatTransport()
|
||||
: IOSGatewayChatTransport(gateway: self.appModel.operatorSession)
|
||||
let response = try await transport.listSessions(limit: CommandCenterTab.recentSessionsFetchLimit)
|
||||
self.sessions = response.sessions
|
||||
} catch {
|
||||
self.sessions = []
|
||||
self.loadErrorText = "Try again after the gateway reconnects."
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ item: CommandCenterTab.WorkItem) {
|
||||
switch item.route {
|
||||
case let .chat(sessionKey):
|
||||
self.appModel.openChat(sessionKey: sessionKey)
|
||||
self.openChat()
|
||||
case .settings:
|
||||
self.openSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Activity states") {
|
||||
IPadActivityStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard states") {
|
||||
IPadWorkboardStatesPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop states") {
|
||||
IPadSkillWorkshopStatesPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop iPad kanban lanes",
|
||||
traits: .fixedLayout(width: 1180, height: 820))
|
||||
{
|
||||
IPadSkillWorkshopKanbanPreview()
|
||||
}
|
||||
|
||||
#Preview("Workboard phone queue rows") {
|
||||
IPadWorkboardCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview("Skill Workshop phone queue rows") {
|
||||
IPadSkillWorkshopCompactRowsPreview()
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Workboard phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadWorkboardScreen(openChat: {}, openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Skill Workshop phone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
IPadSidebarTaskScreenPreviewHost {
|
||||
IPadSkillWorkshopScreen(openSettings: {})
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardCompactRowsPreview: View {
|
||||
private let statuses = ["todo", "ready", "running", "review", "blocked", "done"]
|
||||
private let cards = IPadWorkboardPreviewFixtures.cards
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.cards.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.cards.enumerated()), id: \.element.id) { index, card in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadWorkboardQueueRow(
|
||||
card: card,
|
||||
statuses: self.statuses,
|
||||
isBusy: card.id == "preview-running",
|
||||
inspect: {},
|
||||
openSession: {},
|
||||
move: { _ in },
|
||||
archive: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No cards",
|
||||
detail: "Create a card or change the filter.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone queue")
|
||||
.font(.headline)
|
||||
Text("Tap for detail, swipe or long-press for card actions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopCompactRowsPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.previewHeader
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(self.proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(self.proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == "preview-pending",
|
||||
isBusy: proposal.id == "preview-held")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .compact)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Phone proposals")
|
||||
.font(.headline)
|
||||
Text("Tap for detail, swipe or long-press for proposal actions.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSidebarTaskScreenPreviewHost<Content: View>: View {
|
||||
@State private var appModel = NodeAppModel()
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
self.content
|
||||
}
|
||||
.environment(self.appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadActivityStatesPreview: View {
|
||||
private let connectedSessions = [
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-main",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "Main",
|
||||
detail: "Updated just now",
|
||||
state: "active",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.ok,
|
||||
progress: nil,
|
||||
route: .chat("main")),
|
||||
CommandCenterTab.WorkItem(
|
||||
id: "preview-ipad-audit",
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "iPad audit",
|
||||
detail: "Updated 8m ago",
|
||||
state: "recent",
|
||||
trailing: "open",
|
||||
color: OpenClawBrand.accent,
|
||||
progress: nil,
|
||||
route: .chat("ipad-audit")),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: self.connectedSessions,
|
||||
tailRows: [])
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "Fetching recent activity from the gateway.",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading sessions",
|
||||
detail: "Fetching recent activity from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent),
|
||||
])
|
||||
|
||||
self.previewHeader("Empty")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "tailscale.local:18789",
|
||||
gatewayValue: "online",
|
||||
gatewayColor: OpenClawBrand.ok,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "bubble.left.and.text.bubble.right",
|
||||
title: "No recent sessions",
|
||||
detail: "Start a chat and it will appear here.",
|
||||
value: "empty",
|
||||
color: .secondary),
|
||||
])
|
||||
|
||||
self.previewHeader("Error")
|
||||
self.activityCard(
|
||||
gatewayTitle: "Gateway",
|
||||
gatewayDetail: "No gateway connection",
|
||||
gatewayValue: "offline",
|
||||
gatewayColor: .secondary,
|
||||
sessionRows: [],
|
||||
tailRows: [
|
||||
ActivityPreviewRow(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "Sessions unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn),
|
||||
])
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
|
||||
private func activityCard(
|
||||
gatewayTitle: String,
|
||||
gatewayDetail: String,
|
||||
gatewayValue: String,
|
||||
gatewayColor: Color,
|
||||
sessionRows: [CommandCenterTab.WorkItem],
|
||||
tailRows: [ActivityPreviewRow]) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Recent activity",
|
||||
value: nil,
|
||||
actionTitle: "Refresh",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: gatewayValue == "online" ? "network" : "wifi.slash",
|
||||
title: gatewayTitle,
|
||||
detail: gatewayDetail,
|
||||
value: gatewayValue,
|
||||
color: gatewayColor,
|
||||
actionTitle: gatewayValue == "online" ? nil : "Settings",
|
||||
action: {})
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "square.and.arrow.down",
|
||||
title: "Share intake",
|
||||
detail: "No share events yet.",
|
||||
value: "iPad",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(sessionRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.state,
|
||||
color: row.color,
|
||||
actionTitle: "Open",
|
||||
action: {})
|
||||
}
|
||||
ForEach(tailRows) { row in
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: row.icon,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
value: row.value,
|
||||
color: row.color,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActivityPreviewRow: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadWorkboardStatesPreview: View {
|
||||
private let statuses = ["todo", "running", "review"]
|
||||
private let connectedCards = IPadWorkboardPreviewFixtures.cards
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.connectedBoard
|
||||
|
||||
self.previewHeader("Empty")
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: "todo",
|
||||
cards: [],
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(maxWidth: 320)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "arrow.clockwise",
|
||||
title: "Loading cards",
|
||||
detail: "Refreshing the workboard from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Error")
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Cards unavailable",
|
||||
detail: "Check the gateway connection, then refresh.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: "Retry",
|
||||
action: {})
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectedBoard: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.statuses, id: \.self) { status in
|
||||
IPadWorkboardKanbanColumn(
|
||||
status: status,
|
||||
cards: self.connectedCards.filter { $0.status == status },
|
||||
statuses: self.statuses,
|
||||
busyCardID: nil,
|
||||
openSession: { _ in },
|
||||
inspect: { _ in },
|
||||
move: { _, _ in },
|
||||
archive: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadWorkboardPreviewFixtures {
|
||||
static let cards = [
|
||||
IPadWorkboardCard(
|
||||
id: "preview-todo",
|
||||
title: "Prep iPad sidebar audit",
|
||||
notes: "Confirm portrait drawer behavior before device install.",
|
||||
status: "todo",
|
||||
priority: "normal",
|
||||
labels: ["iPad", "UI"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 0,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-running",
|
||||
title: "Verify phone workboard queue",
|
||||
notes: "Single-list compact flow with detail sheet actions.",
|
||||
status: "running",
|
||||
priority: "high",
|
||||
labels: ["phone"],
|
||||
agentId: "main",
|
||||
sessionKey: "session-preview",
|
||||
position: 1,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
IPadWorkboardCard(
|
||||
id: "preview-review",
|
||||
title: "Review adaptive shell",
|
||||
notes: "Make sure shared destinations stay device-specific.",
|
||||
status: "review",
|
||||
priority: "normal",
|
||||
labels: ["shell"],
|
||||
agentId: "main",
|
||||
sessionKey: nil,
|
||||
position: 2,
|
||||
updatedAt: nil,
|
||||
metadata: nil),
|
||||
]
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopStatesPreview: View {
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader("Connected")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: nil)
|
||||
|
||||
self.previewHeader("Loading")
|
||||
self.queueCard(self.proposals, selectedID: "preview-pending", busyID: "preview-pending")
|
||||
|
||||
self.previewHeader("Empty")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "hammer",
|
||||
title: "No proposals",
|
||||
detail: "New proposals will appear here when agents draft skills.",
|
||||
value: "empty",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
|
||||
self.previewHeader("Offline / Error")
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Workshop offline",
|
||||
detail: "Connect to the gateway to load Skill Workshop proposals.",
|
||||
value: "offline",
|
||||
color: .secondary,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
Divider().padding(.leading, 58)
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Proposal unavailable",
|
||||
detail: "Try again after the gateway reconnects.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn,
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewHeader(_ title: String) -> some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private func queueCard(
|
||||
_ proposals: [IPadSkillProposal],
|
||||
selectedID: String?,
|
||||
busyID: String?) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Queue",
|
||||
value: "\(proposals.count)",
|
||||
actionTitle: nil,
|
||||
action: nil)
|
||||
ForEach(Array(proposals.enumerated()), id: \.element.id) { index, proposal in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
IPadSkillProposalRow(
|
||||
proposal: proposal,
|
||||
isSelected: proposal.id == selectedID,
|
||||
isBusy: proposal.id == busyID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IPadSkillWorkshopKanbanPreview: View {
|
||||
private let lanes = IPadSkillWorkshopPreviewFixtures.kanbanStatuses
|
||||
private let proposals = IPadSkillWorkshopPreviewFixtures.proposals
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
self.previewHeader
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ForEach(self.lanes, id: \.self) { status in
|
||||
IPadSkillProposalKanbanColumn(
|
||||
status: status,
|
||||
proposals: self.proposals.filter { $0.status == status },
|
||||
selectedProposalID: "preview-pending",
|
||||
inspectingProposalID: "preview-needs-review",
|
||||
canApplyProposalMutations: true,
|
||||
busyAction: nil,
|
||||
select: { _ in },
|
||||
inspect: { _ in },
|
||||
apply: { _ in },
|
||||
reject: { _ in })
|
||||
.frame(width: 282)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 22)
|
||||
}
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .regular)
|
||||
}
|
||||
|
||||
private var previewHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("iPad kanban")
|
||||
.font(.headline)
|
||||
Text("Wide layout with populated, empty, held, and custom proposal lanes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private enum IPadSkillWorkshopPreviewFixtures {
|
||||
static let kanbanStatuses = [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
"manual_QA",
|
||||
]
|
||||
|
||||
static let proposals = [
|
||||
Self.proposal(
|
||||
id: "preview-pending",
|
||||
status: "pending",
|
||||
title: "Add Tailscale gateway helper",
|
||||
description: "Drafts a helper skill for checking local Tailscale reachability before pairing.",
|
||||
minutesAgo: 9),
|
||||
Self.proposal(
|
||||
id: "preview-applied",
|
||||
status: "applied",
|
||||
title: "Summarize channel health",
|
||||
description: "Adds a lightweight status summary for channel clients and recent routing failures.",
|
||||
minutesAgo: 47),
|
||||
Self.proposal(
|
||||
id: "preview-held",
|
||||
status: "quarantined",
|
||||
title: "Desktop automation bridge",
|
||||
description: "Held for review because it requests broader file access than mobile should expose.",
|
||||
minutesAgo: 128),
|
||||
Self.proposal(
|
||||
id: "preview-needs-review",
|
||||
status: "needs-review",
|
||||
title: "Review pairing diagnostics",
|
||||
description: "Adds a diagnostic checklist before trusting a new gateway certificate.",
|
||||
minutesAgo: 32),
|
||||
Self.proposal(
|
||||
id: "preview-manual-qa",
|
||||
status: "manual_QA",
|
||||
title: "Manual QA runbook",
|
||||
description: "Generates a device checklist for iPhone portrait and iPad split layouts.",
|
||||
minutesAgo: 15),
|
||||
]
|
||||
|
||||
private static func proposal(
|
||||
id: String,
|
||||
status: String,
|
||||
title: String,
|
||||
description: String,
|
||||
minutesAgo: Int) -> IPadSkillProposal
|
||||
{
|
||||
let updatedAt = ISO8601DateFormatter().string(from: Date().addingTimeInterval(Double(-minutesAgo * 60)))
|
||||
return IPadSkillProposal(
|
||||
entry: IPadSkillProposalManifestEntry(
|
||||
id: id,
|
||||
kind: "skill",
|
||||
status: status,
|
||||
title: title,
|
||||
description: description,
|
||||
skillName: title,
|
||||
skillKey: id,
|
||||
createdAt: updatedAt,
|
||||
updatedAt: updatedAt,
|
||||
scanState: "complete"),
|
||||
previous: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,17 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct EmptyParams: Encodable {}
|
||||
|
||||
enum IPadSidebarGatewayError: Error {
|
||||
case offline
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .offline:
|
||||
"Gateway offline."
|
||||
case .invalidPayload:
|
||||
"Could not encode request."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct IPadSidebarScreenChrome<Content: View>: View {
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
gatewayAction: (() -> Void)? = nil,
|
||||
@ViewBuilder content: () -> Content)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title,
|
||||
subtitle: self.subtitle,
|
||||
titleFont: self.isCompactHeight ? .headline.weight(.semibold) : .title2.weight(.semibold),
|
||||
subtitleLineLimit: self.isCompactHeight ? 1 : 2)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
self.content
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 18)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
self.isCompactHeight ? 150 : OpenClawProMetric.bottomScrollInset
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,129 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OpenClawDocsScreen: View {
|
||||
private let docsURL = URL(string: "https://docs.openclaw.ai")!
|
||||
private let gatewayURL = URL(string: "https://docs.openclaw.ai/gateway")!
|
||||
private let pairingURL = URL(string: "https://docs.openclaw.ai/channels/pairing")!
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.headerCard
|
||||
self.linkCard
|
||||
self.versionCard
|
||||
}
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Docs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Docs",
|
||||
subtitle: "Gateway setup, pairing, channels, and mobile node reference.",
|
||||
titleFont: .headline,
|
||||
subtitleFont: .caption)
|
||||
{
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
ProIconBadge(systemName: "book", color: OpenClawBrand.accent)
|
||||
}
|
||||
} accessory: {
|
||||
self.gatewayPill
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
|
||||
private var linkCard: some View {
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.docsLinkRow(
|
||||
title: "Docs Home",
|
||||
detail: "Browse the current OpenClaw reference.",
|
||||
icon: "book",
|
||||
url: self.docsURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Gateway",
|
||||
detail: "Connection, auth, and diagnostics.",
|
||||
icon: "network",
|
||||
url: self.gatewayURL)
|
||||
Divider().padding(.leading, 58)
|
||||
self.docsLinkRow(
|
||||
title: "Pairing",
|
||||
detail: "Mobile setup codes, QR, and node approval.",
|
||||
icon: "qrcode",
|
||||
url: self.pairingURL)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var versionCard: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Version")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private func docsLinkRow(title: String, detail: String, icon: String, url: URL) -> some View {
|
||||
Link(destination: url) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: icon, color: OpenClawBrand.accent)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
import SwiftUI
|
||||
|
||||
enum OpenClawProMetric {
|
||||
static let pagePadding: CGFloat = 18
|
||||
static let cardRadius: CGFloat = 10
|
||||
static let controlRadius: CGFloat = 8
|
||||
static let pagePadding: CGFloat = 20
|
||||
static let cardRadius: CGFloat = 14
|
||||
static let controlRadius: CGFloat = 12
|
||||
static let bottomScrollInset: CGFloat = 96
|
||||
static let heroRadius: CGFloat = 12
|
||||
static let heroRadius: CGFloat = 22
|
||||
}
|
||||
|
||||
struct OpenClawProBackground: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Color(uiColor: self.colorScheme == .dark ? .systemBackground : .systemGroupedBackground)
|
||||
LinearGradient(
|
||||
colors: OpenClawBrand.canvasColors(for: self.colorScheme),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if self.colorScheme == .light {
|
||||
Color.white.opacity(0.22)
|
||||
.frame(height: 140)
|
||||
LinearGradient(
|
||||
colors: [
|
||||
OpenClawBrand.accent.opacity(0.05),
|
||||
OpenClawBrand.accent.opacity(0.02),
|
||||
.clear,
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading)
|
||||
.frame(height: 620)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
@@ -56,7 +66,7 @@ struct ProSectionHeader: View {
|
||||
struct ProCard<Content: View>: View {
|
||||
var tint: Color?
|
||||
var isProminent: Bool = false
|
||||
var padding: CGFloat = 12
|
||||
var padding: CGFloat = 14
|
||||
var radius: CGFloat = OpenClawProMetric.cardRadius
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
@@ -81,39 +91,78 @@ private struct ProPanelBackground: 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 {
|
||||
if self.isProminent {
|
||||
shape.strokeBorder(
|
||||
OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.12 : 0.07),
|
||||
lineWidth: 1)
|
||||
.padding(1)
|
||||
}
|
||||
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 {
|
||||
let base = self.isProminent
|
||||
? Color(uiColor: .systemBackground)
|
||||
: Color(uiColor: .secondarySystemGroupedBackground)
|
||||
if let tint {
|
||||
let gradient = LinearGradient(
|
||||
colors: [
|
||||
base,
|
||||
tint.opacity(self.colorScheme == .dark ? 0.08 : 0.045),
|
||||
base,
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing)
|
||||
return AnyShapeStyle(gradient)
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
AnyShapeStyle(Color(uiColor: .separator).opacity(self.colorScheme == .dark ? 0.26 : 0.30))
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +251,9 @@ private struct ProPanelSurfaceModifier: ViewModifier {
|
||||
}
|
||||
.modifier(ProLightGlassModifier(radius: self.radius))
|
||||
.shadow(
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.22) : .black.opacity(0.028),
|
||||
radius: self.isProminent ? 9 : 4,
|
||||
y: self.isProminent ? 4 : 1)
|
||||
color: self.colorScheme == .dark ? .black.opacity(0.60) : .black.opacity(0.045),
|
||||
radius: self.isProminent ? 20 : 12,
|
||||
y: self.isProminent ? 10 : 6)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,160 +263,16 @@ struct ProIconBadge: View {
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: self.systemName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(self.color)
|
||||
.frame(width: 30, height: 30)
|
||||
.frame(width: 34, height: 34)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(self.color.opacity(0.12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderAction {
|
||||
let systemName: String
|
||||
let accessibilityLabel: String
|
||||
let accessibilityIdentifier: String?
|
||||
let action: () -> Void
|
||||
|
||||
init(
|
||||
systemName: String,
|
||||
accessibilityLabel: String,
|
||||
accessibilityIdentifier: String? = nil,
|
||||
action: @escaping () -> Void)
|
||||
{
|
||||
self.systemName = systemName
|
||||
self.accessibilityLabel = accessibilityLabel
|
||||
self.accessibilityIdentifier = accessibilityIdentifier
|
||||
self.action = action
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarRevealButton: View {
|
||||
let headerAction: OpenClawSidebarHeaderAction
|
||||
|
||||
init(action: OpenClawSidebarHeaderAction) {
|
||||
self.headerAction = action
|
||||
}
|
||||
|
||||
init(action: @escaping () -> Void) {
|
||||
self.headerAction = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: action)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let button = Button(action: self.headerAction.action) {
|
||||
Image(systemName: self.headerAction.systemName)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: 38, height: 38)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel(self.headerAction.accessibilityLabel)
|
||||
|
||||
if let accessibilityIdentifier = self.headerAction.accessibilityIdentifier {
|
||||
button.accessibilityIdentifier(accessibilityIdentifier)
|
||||
} else {
|
||||
button
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawSidebarHeaderLeadingSlot: View {
|
||||
let action: OpenClawSidebarHeaderAction
|
||||
|
||||
var body: some View {
|
||||
OpenClawSidebarRevealButton(action: self.action)
|
||||
.frame(width: 44, height: 44, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
var titleFont: Font = .title3.weight(.semibold)
|
||||
var subtitleFont: Font = .subheadline
|
||||
var subtitleLineLimit: Int? = 2
|
||||
@ViewBuilder let leading: Leading
|
||||
@ViewBuilder let accessory: Accessory
|
||||
|
||||
init(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
titleFont: Font = .title3.weight(.semibold),
|
||||
subtitleFont: Font = .subheadline,
|
||||
subtitleLineLimit: Int? = 2,
|
||||
@ViewBuilder leading: () -> Leading,
|
||||
@ViewBuilder accessory: () -> Accessory)
|
||||
{
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.titleFont = titleFont
|
||||
self.subtitleFont = subtitleFont
|
||||
self.subtitleLineLimit = subtitleLineLimit
|
||||
self.leading = leading()
|
||||
self.accessory = accessory()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
self.horizontalLayout
|
||||
self.stackedLayout
|
||||
}
|
||||
}
|
||||
|
||||
private var horizontalLayout: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
|
||||
private var stackedLayout: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
self.leading
|
||||
|
||||
self.titleBlock
|
||||
.layoutPriority(1)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer(minLength: 0)
|
||||
self.accessory
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var titleBlock: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(self.titleFont)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.86)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(self.subtitle)
|
||||
.font(self.subtitleFont)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(self.subtitleLineLimit)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusDot: View {
|
||||
var color: Color
|
||||
|
||||
@@ -375,6 +280,7 @@ struct ProStatusDot: View {
|
||||
Circle()
|
||||
.fill(self.color)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: self.color.opacity(0.35), radius: 4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +312,7 @@ struct OpenClawProMark: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: self.size, height: self.size)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.18), radius: self.shadowRadius, y: self.shadowRadius / 3)
|
||||
.shadow(color: OpenClawBrand.accent.opacity(0.28), radius: self.shadowRadius, y: self.shadowRadius / 2)
|
||||
.accessibilityLabel("OpenClaw")
|
||||
}
|
||||
}
|
||||
@@ -484,10 +390,7 @@ struct ProCapsule: View {
|
||||
}
|
||||
Text(self.title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.foregroundStyle(self.color)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
@@ -502,57 +405,6 @@ struct ProCapsule: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenClawGatewayCompactPill: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
|
||||
var body: some View {
|
||||
ProCapsule(
|
||||
title: self.title,
|
||||
color: self.color,
|
||||
icon: self.icon)
|
||||
.accessibilityLabel("Gateway \(self.title)")
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"checkmark.circle.fill"
|
||||
case .connecting:
|
||||
"arrow.triangle.2.circlepath"
|
||||
case .error:
|
||||
"exclamationmark.triangle.fill"
|
||||
case .disconnected:
|
||||
"wifi.slash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProSegmentedControl: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
let labels: [String]
|
||||
@@ -679,120 +531,28 @@ struct ProMetricTile: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ProMetric: Identifiable {
|
||||
let id = UUID()
|
||||
let icon: String
|
||||
let title: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct ProMetricGrid: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
let metrics: [ProMetric]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(
|
||||
columns: Array(repeating: GridItem(.flexible()), count: self.columnCount),
|
||||
spacing: 10)
|
||||
{
|
||||
ForEach(self.metrics) { metric in
|
||||
ProMetricTile(
|
||||
title: metric.title,
|
||||
value: metric.value,
|
||||
icon: metric.icon,
|
||||
color: metric.color)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var columnCount: Int {
|
||||
guard self.horizontalSizeClass != .compact else { return 1 }
|
||||
return min(max(self.metrics.count, 1), 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProPanelHeader: View {
|
||||
let title: String
|
||||
var value: String?
|
||||
var actionTitle: String?
|
||||
var actionIcon: String?
|
||||
var actionAccessibilityLabel: String?
|
||||
var isActionDisabled = false
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.actionControl
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionControl: some View {
|
||||
if let action {
|
||||
if let actionIcon {
|
||||
Button(action: action) {
|
||||
Image(systemName: actionIcon)
|
||||
}
|
||||
.accessibilityLabel(self.actionAccessibilityLabel ?? self.actionTitle ?? self.title)
|
||||
.disabled(self.isActionDisabled)
|
||||
} else if let actionTitle {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.disabled(self.isActionDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProStatusRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let value: String?
|
||||
let value: String
|
||||
let color: Color
|
||||
var actionTitle: String?
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: self.icon, color: self.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
VStack(alignment: .trailing, spacing: 6) {
|
||||
if let value {
|
||||
ProValuePill(value: value, color: self.color)
|
||||
}
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.caption.weight(.semibold))
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
ProValuePill(value: self.value, color: self.color)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct RootTabsPhoneControlHub: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
@State private var navigationPath: [RootTabs.SidebarDestination] = []
|
||||
@State private var didApplyInitialDestination = false
|
||||
|
||||
let groups: [RootTabs.SidebarGroup]
|
||||
let initialDestination: RootTabs.SidebarDestination?
|
||||
let openRootDestination: (RootTabs.SidebarDestination) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 10 : 16) {
|
||||
self.headerCard
|
||||
ForEach(self.groups) { group in
|
||||
self.groupSection(group)
|
||||
}
|
||||
self.versionFooter
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 10 : 16)
|
||||
}
|
||||
.safeAreaPadding(.bottom, self.bottomScrollInset)
|
||||
}
|
||||
.navigationTitle("Control")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(for: RootTabs.SidebarDestination.self) { destination in
|
||||
self.detail(for: destination)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
.onAppear {
|
||||
self.applyInitialDestinationIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var headerCard: some View {
|
||||
if self.isCompactHeight {
|
||||
ProCard(padding: 8, radius: OpenClawProMetric.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 24, shadowRadius: 3)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
} else {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
OpenClawProMark(size: 32, shadowRadius: 4)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(self.sidebarActiveAgentTitle)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.gatewayStateText, color: self.gatewayStateColor)
|
||||
}
|
||||
|
||||
self.gatewayActionRow
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionRow: some View {
|
||||
Button {
|
||||
self.openRootDestination(.gateway)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
ProStatusDot(color: self.gatewayStateColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.gatewayStateText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(self.gatewayDisplayLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Text(self.gatewayActionTitle)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.primary.opacity(0.055), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Gateway \(self.gatewayStateText)")
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
}
|
||||
|
||||
private func groupSection(_ group: RootTabs.SidebarGroup) -> some View {
|
||||
VStack(alignment: .leading, spacing: self.isCompactHeight ? 6 : 8) {
|
||||
ProSectionHeader(title: group.title.capitalized)
|
||||
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(group.destinations.enumerated()), id: \.element.id) { index, destination in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
self.destinationRow(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func destinationRow(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
if self.opensRootTab(destination) {
|
||||
Button {
|
||||
self.openRootDestination(destination)
|
||||
} label: {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
Button {
|
||||
self.navigationPath.append(destination)
|
||||
} label: {
|
||||
self.rowLabel(destination)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func rowLabel(_ destination: RootTabs.SidebarDestination) -> some View {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ProIconBadge(systemName: destination.systemImage, color: self.color(for: destination))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(destination.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(destination.subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, self.isCompactHeight ? 8 : 10)
|
||||
.padding(.horizontal, 14)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var versionFooter: some View {
|
||||
ProCard(radius: OpenClawProMetric.cardRadius) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detail(for destination: RootTabs.SidebarDestination) -> some View {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents, .gateway:
|
||||
EmptyView()
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
headerTitle: "Overview",
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) },
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
openChat: { self.openRootDestination(.chat) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.openRootDestination(.gateway) })
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
headerLeadingAction: self.phoneDetailBackAction,
|
||||
gatewayAction: { self.openRootDestination(.gateway) })
|
||||
case .settings:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneDetailBackAction: OpenClawSidebarHeaderAction {
|
||||
OpenClawSidebarHeaderAction(
|
||||
systemName: "chevron.left",
|
||||
accessibilityLabel: "Back to Control",
|
||||
accessibilityIdentifier: "OpenClawPhoneDetailBackButton",
|
||||
action: { self.popPhoneDetail() })
|
||||
}
|
||||
|
||||
private func popPhoneDetail() {
|
||||
guard !self.navigationPath.isEmpty else { return }
|
||||
self.navigationPath.removeLast()
|
||||
}
|
||||
|
||||
private func opensRootTab(_ destination: RootTabs.SidebarDestination) -> Bool {
|
||||
RootTabs.shouldOpenRootTabFromPhoneHub(destination)
|
||||
}
|
||||
|
||||
private func applyInitialDestinationIfNeeded() {
|
||||
guard !self.didApplyInitialDestination else { return }
|
||||
self.didApplyInitialDestination = true
|
||||
guard let initialDestination, initialDestination != .overview else { return }
|
||||
if self.opensRootTab(initialDestination) {
|
||||
self.openRootDestination(initialDestination)
|
||||
} else {
|
||||
self.navigationPath = [initialDestination]
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarActiveAgentTitle: String {
|
||||
let selectedID = self.normalized(self.appModel.selectedAgentId) ?? self.resolveDefaultAgentID()
|
||||
if let agent = self.appModel.gatewayAgents.first(where: { $0.id == selectedID }) {
|
||||
return self.agentTitle(for: agent)
|
||||
}
|
||||
return self.normalized(self.appModel.activeAgentName) ?? "Default Agent"
|
||||
}
|
||||
|
||||
private var gatewayDisplayLabel: String {
|
||||
self.normalized(self.appModel.gatewayServerName)
|
||||
?? self.normalized(self.appModel.gatewayRemoteAddress)
|
||||
?? self.appModel.gatewayDisplayStatusText
|
||||
}
|
||||
|
||||
private var gatewayStateText: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected: "Online"
|
||||
case .connecting: "Connecting"
|
||||
case .error: "Attention"
|
||||
case .disconnected: "Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStateColor: Color {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayActionTitle: String {
|
||||
switch GatewayStatusBuilder.build(appModel: self.appModel) {
|
||||
case .connected:
|
||||
"Manage"
|
||||
case .connecting:
|
||||
"Details"
|
||||
case .error:
|
||||
"Fix"
|
||||
case .disconnected:
|
||||
"Connect"
|
||||
}
|
||||
}
|
||||
|
||||
private var isCompactHeight: Bool {
|
||||
self.verticalSizeClass == .compact
|
||||
}
|
||||
|
||||
private var bottomScrollInset: CGFloat {
|
||||
Self.bottomScrollInset(verticalSizeClass: self.verticalSizeClass)
|
||||
}
|
||||
|
||||
static func bottomScrollInset(verticalSizeClass: UserInterfaceSizeClass?) -> CGFloat {
|
||||
verticalSizeClass == .compact ? 72 : 112
|
||||
}
|
||||
|
||||
private func color(for destination: RootTabs.SidebarDestination) -> Color {
|
||||
switch destination {
|
||||
case .chat, .talk, .overview, .gateway:
|
||||
OpenClawBrand.accent
|
||||
case .instances:
|
||||
Color.secondary
|
||||
case .activity, .usage, .docs:
|
||||
OpenClawBrand.accentHot
|
||||
case .agents, .workboard, .skillWorkshop, .sessions, .dreaming, .cron, .settings:
|
||||
OpenClawBrand.ok
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDefaultAgentID() -> String {
|
||||
self.normalized(self.appModel.gatewayDefaultAgentId) ?? ""
|
||||
}
|
||||
|
||||
private func agentTitle(for agent: AgentSummary) -> String {
|
||||
let name = self.normalized(agent.name) ?? agent.id
|
||||
return name == agent.id ? name : "\(name) (\(agent.id))"
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Phone control hub offline") {
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connected") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub connecting") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview("Phone control hub gateway error") {
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
return RootTabsPhoneControlHub.preview(appModel: appModel)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Phone control hub landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPhoneControlHub.preview(appModel: NodeAppModel())
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
extension RootTabsPhoneControlHub {
|
||||
fileprivate static func preview(appModel: NodeAppModel) -> some View {
|
||||
RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,726 +0,0 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsChannelsDestination: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
let showsSummaryCard: Bool
|
||||
@State private var snapshot: ChannelsStatusResult?
|
||||
@State private var isLoading = false
|
||||
@State private var errorText: String?
|
||||
@State private var busyOperation: SettingsChannelOperation?
|
||||
|
||||
init(showsSummaryCard: Bool = true) {
|
||||
self.showsSummaryCard = showsSummaryCard
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.showsSummaryCard {
|
||||
self.summaryCard
|
||||
}
|
||||
self.channelsCard
|
||||
}
|
||||
.task(id: self.refreshID) {
|
||||
await self.loadChannels(force: false)
|
||||
}
|
||||
.refreshable {
|
||||
await self.loadChannels(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 12) {
|
||||
ProIconBadge(systemName: "point.3.connected.trianglepath.dotted", color: self.summaryColor)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.headline)
|
||||
Text(self.summaryDetail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.summaryValue, color: self.summaryColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var channelsCard: some View {
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: self.headerValue,
|
||||
actionIcon: self.isLoading ? "hourglass" : "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: self.isLoading,
|
||||
action: {
|
||||
Task { await self.loadChannels(force: true) }
|
||||
})
|
||||
|
||||
if let errorText {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: errorText,
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
} else if !self.canRead {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
} else if self.isLoading, self.snapshot == nil {
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channels",
|
||||
detail: "Fetching installed channels, accounts, and routing status from the gateway.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
} else if self.channelEntries.isEmpty {
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
} else {
|
||||
ForEach(Array(self.channelEntries.enumerated()), id: \.element.id) { index, entry in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 58)
|
||||
}
|
||||
SettingsChannelRow(
|
||||
entry: entry,
|
||||
canAdmin: self.canAdmin,
|
||||
busyOperation: self.busyOperation,
|
||||
start: { accountID in
|
||||
Task { await self.run(.start, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
stop: { accountID in
|
||||
Task { await self.run(.stop, channelID: entry.id, accountID: accountID) }
|
||||
},
|
||||
logout: { accountID in
|
||||
Task { await self.run(.logout, channelID: entry.id, accountID: accountID) }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
private var refreshID: String {
|
||||
[
|
||||
self.canRead ? "connected" : "offline",
|
||||
self.scenePhase == .active ? "active" : "inactive",
|
||||
].joined(separator: ":")
|
||||
}
|
||||
|
||||
private var canRead: Bool {
|
||||
self.appModel.isOperatorGatewayConnected
|
||||
}
|
||||
|
||||
private var canAdmin: Bool {
|
||||
self.appModel.hasOperatorAdminScope
|
||||
}
|
||||
|
||||
static func shouldEnableChannelOperation(canRead: Bool, hasOperatorAdminScope: Bool) -> Bool {
|
||||
canRead && hasOperatorAdminScope
|
||||
}
|
||||
|
||||
private var headerValue: String? {
|
||||
if self.isLoading { return "Loading" }
|
||||
guard self.canRead else { return "Offline" }
|
||||
return "\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryDetail: String {
|
||||
guard self.canRead else {
|
||||
return "Connect to load channel integrations."
|
||||
}
|
||||
if let errorText {
|
||||
return errorText
|
||||
}
|
||||
return "Installed channel clients, account state, and message-routing readiness."
|
||||
}
|
||||
|
||||
private var summaryValue: String {
|
||||
guard self.canRead else { return "offline" }
|
||||
if self.isLoading { return "loading" }
|
||||
if self.errorText != nil { return "error" }
|
||||
let configured = self.channelEntries.count(where: { $0.configured })
|
||||
return "\(configured)/\(self.channelEntries.count)"
|
||||
}
|
||||
|
||||
private var summaryColor: Color {
|
||||
guard self.canRead else { return .secondary }
|
||||
if self.errorText != nil { return OpenClawBrand.warn }
|
||||
return self.channelEntries.contains(where: { $0.running || $0.connected }) ? OpenClawBrand.ok : OpenClawBrand
|
||||
.accent
|
||||
}
|
||||
|
||||
private var channelEntries: [SettingsChannelEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let ids = snapshot.channelorder.isEmpty ? Array(snapshot.channels.keys).sorted() : snapshot.channelorder
|
||||
return ids.map { self.entry(channelID: $0, snapshot: snapshot) }
|
||||
}
|
||||
|
||||
private func entry(channelID: String, snapshot: ChannelsStatusResult) -> SettingsChannelEntry {
|
||||
let summary = snapshot.channels[channelID]?.dictionaryValue ?? [:]
|
||||
let accounts = self.accounts(channelID: channelID, snapshot: snapshot)
|
||||
let configured = accounts.contains(where: \.configured) || summary["configured"]?.boolValue == true
|
||||
let running = accounts.contains(where: \.running)
|
||||
let connected = accounts.contains(where: \.connected)
|
||||
let linked = accounts.contains(where: \.linked)
|
||||
let label = snapshot.channellabels[channelID]?.stringValue ?? Self.fallbackLabel(channelID)
|
||||
let detail = snapshot.channeldetaillabels?[channelID]?.stringValue ?? Self.fallbackDetail(channelID)
|
||||
let systemImage = snapshot.channelsystemimages?[channelID]?.stringValue ?? Self.fallbackSystemImage(channelID)
|
||||
let lastActivity = accounts.compactMap(\.lastActivityMs).max()
|
||||
let lastError = accounts.compactMap(\.lastError).first ?? summary["lastError"]?.stringValue
|
||||
return SettingsChannelEntry(
|
||||
id: channelID,
|
||||
label: label,
|
||||
detail: detail,
|
||||
systemImage: systemImage,
|
||||
configured: configured,
|
||||
running: running,
|
||||
connected: connected,
|
||||
linked: linked,
|
||||
lastActivityText: lastActivity.map(Self.relativeTime),
|
||||
lastError: lastError,
|
||||
unavailableReason: configured ? nil : "Configure this channel on the gateway.",
|
||||
accounts: accounts)
|
||||
}
|
||||
|
||||
private func accounts(channelID: String, snapshot: ChannelsStatusResult) -> [SettingsChannelAccount] {
|
||||
let rawAccounts = snapshot.channelaccounts[channelID]?.arrayValue ?? []
|
||||
return rawAccounts.compactMap { raw in
|
||||
guard let dict = raw.dictionaryValue else { return nil }
|
||||
let accountID = dict["accountId"]?.stringValue ?? "default"
|
||||
let name = dict["name"]?.stringValue
|
||||
let lastActivity = [
|
||||
dict["lastInboundAt"]?.intValue,
|
||||
dict["lastOutboundAt"]?.intValue,
|
||||
dict["lastTransportActivityAt"]?.intValue,
|
||||
]
|
||||
.compactMap(\.self)
|
||||
.max()
|
||||
return SettingsChannelAccount(
|
||||
id: accountID,
|
||||
name: name,
|
||||
configured: dict["configured"]?.boolValue == true,
|
||||
enabled: dict["enabled"]?.boolValue != false,
|
||||
running: dict["running"]?.boolValue == true,
|
||||
connected: dict["connected"]?.boolValue == true,
|
||||
linked: dict["linked"]?.boolValue == true,
|
||||
healthState: dict["healthState"]?.stringValue,
|
||||
lastError: dict["lastError"]?.stringValue,
|
||||
lastActivityMs: lastActivity)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChannels(force: Bool) async {
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard self.canRead else {
|
||||
self.snapshot = nil
|
||||
self.errorText = nil
|
||||
return
|
||||
}
|
||||
if self.isLoading { return }
|
||||
|
||||
self.isLoading = true
|
||||
self.errorText = nil
|
||||
defer { self.isLoading = false }
|
||||
|
||||
do {
|
||||
let params = ChannelsStatusParams(probe: false, timeoutms: 10000, channel: nil)
|
||||
let data = try await self.request(method: "channels.status", params: params, timeoutSeconds: 12)
|
||||
self.snapshot = try JSONDecoder().decode(ChannelsStatusResult.self, from: data)
|
||||
} catch {
|
||||
if force || self.snapshot == nil {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func run(_ kind: SettingsChannelOperation.Kind, channelID: String, accountID: String?) async {
|
||||
guard Self.shouldEnableChannelOperation(canRead: self.canRead, hasOperatorAdminScope: self.canAdmin),
|
||||
self.busyOperation == nil
|
||||
else {
|
||||
return
|
||||
}
|
||||
self.busyOperation = SettingsChannelOperation(kind: kind, channelID: channelID, accountID: accountID)
|
||||
self.errorText = nil
|
||||
defer { self.busyOperation = nil }
|
||||
|
||||
do {
|
||||
switch kind {
|
||||
case .start:
|
||||
let params = ChannelsStartParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.start", params: params, timeoutSeconds: 20)
|
||||
case .stop:
|
||||
let params = ChannelsStopParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.stop", params: params, timeoutSeconds: 20)
|
||||
case .logout:
|
||||
let params = ChannelsLogoutParams(channel: channelID, accountid: accountID)
|
||||
_ = try await self.request(method: "channels.logout", params: params, timeoutSeconds: 20)
|
||||
}
|
||||
await self.loadChannels(force: true)
|
||||
} catch {
|
||||
self.errorText = Self.message(for: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func request(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 SettingsChannelError.invalidPayload
|
||||
}
|
||||
return try await self.appModel.operatorSession.request(
|
||||
method: method,
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
static func fallbackLabel(_ id: String) -> String {
|
||||
if let metadata = self.fallbackMetadata[id.lowercased()] {
|
||||
return metadata.label
|
||||
}
|
||||
return id.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.split(separator: " ")
|
||||
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func fallbackDetail(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.detail ?? "Channel integration"
|
||||
}
|
||||
|
||||
static func fallbackSystemImage(_ id: String) -> String {
|
||||
self.fallbackMetadata[id.lowercased()]?.systemImage ?? "bubble.left.and.text.bubble.right"
|
||||
}
|
||||
|
||||
private static let fallbackMetadata: [String: SettingsChannelFallbackMetadata] = [
|
||||
"clickclack": SettingsChannelFallbackMetadata(
|
||||
label: "ClickClack",
|
||||
detail: "Self-hosted chat bot routing.",
|
||||
systemImage: "bubble.left.and.bubble.right"),
|
||||
]
|
||||
|
||||
private static func relativeTime(_ milliseconds: Int) -> String {
|
||||
let age = max(0, Int(Date().timeIntervalSince1970 * 1000) - milliseconds)
|
||||
let minutes = age / 60000
|
||||
if minutes < 1 { return "now" }
|
||||
if minutes < 60 { return "\(minutes)m ago" }
|
||||
let hours = minutes / 60
|
||||
if hours < 24 { return "\(hours)h ago" }
|
||||
return "\(hours / 24)d ago"
|
||||
}
|
||||
|
||||
private static func message(for error: Error) -> String {
|
||||
if let channelError = error as? SettingsChannelError {
|
||||
return channelError.message
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsChannelsScreen: View {
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
let gatewayAction: (() -> Void)?
|
||||
|
||||
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.gatewayAction = gatewayAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
SettingsChannelsDestination(showsSummaryCard: false)
|
||||
}
|
||||
.padding(.top, 18)
|
||||
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Channels")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("Channels / Integrations")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Message routing and external channel clients.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
self.gatewayPill
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var gatewayPill: some View {
|
||||
if let gatewayAction {
|
||||
Button(action: gatewayAction) {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Opens Settings / Gateway")
|
||||
} else {
|
||||
OpenClawGatewayCompactPill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelRow: View {
|
||||
let entry: SettingsChannelEntry
|
||||
let canAdmin: Bool
|
||||
let busyOperation: SettingsChannelOperation?
|
||||
let start: (String?) -> Void
|
||||
let stop: (String?) -> Void
|
||||
let logout: (String?) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ProIconBadge(systemName: self.entry.systemImage, color: self.entry.color)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.entry.label)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(self.entry.detailText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let lastError = self.entry.lastError {
|
||||
Text(lastError)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(OpenClawBrand.warn)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
ProValuePill(value: self.entry.statusValue, color: self.entry.color)
|
||||
}
|
||||
|
||||
if !self.entry.accounts.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(self.entry.accounts.enumerated()), id: \.element.id) { index, account in
|
||||
if index > 0 {
|
||||
Divider().padding(.leading, 38)
|
||||
}
|
||||
self.accountRow(account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private func accountRow(_ account: SettingsChannelAccount) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: account.running || account.connected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(account.color)
|
||||
.frame(width: 28, height: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(account.displayName)
|
||||
.font(.caption.weight(.semibold))
|
||||
Text(account.detailText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Menu {
|
||||
if account.running {
|
||||
Button("Stop") {
|
||||
self.stop(account.id)
|
||||
}
|
||||
} else {
|
||||
Button("Start") {
|
||||
self.start(account.id)
|
||||
}
|
||||
.disabled(!account.configured || !account.enabled)
|
||||
}
|
||||
if account.linked {
|
||||
Button("Logout", role: .destructive) {
|
||||
self.logout(account.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: self.actionMenuIcon(account))
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.mini)
|
||||
.disabled(!self.canAdmin || self.isBusy(account))
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
private func actionMenuIcon(_ account: SettingsChannelAccount) -> String {
|
||||
if self.isBusy(account) {
|
||||
return "hourglass"
|
||||
}
|
||||
if !self.canAdmin {
|
||||
return "lock.shield"
|
||||
}
|
||||
return "ellipsis.circle"
|
||||
}
|
||||
|
||||
private func isBusy(_ account: SettingsChannelAccount) -> Bool {
|
||||
self.busyOperation?.channelID == self.entry.id && self.busyOperation?.accountID == account.id
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelEntry: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
let configured: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let lastActivityText: String?
|
||||
let lastError: String?
|
||||
let unavailableReason: String?
|
||||
let accounts: [SettingsChannelAccount]
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
|
||||
var statusValue: String {
|
||||
if self.connected { return "connected" }
|
||||
if self.running { return "running" }
|
||||
if self.linked { return "linked" }
|
||||
if self.configured { return "configured" }
|
||||
return "not set"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
if let lastActivityText {
|
||||
return "\(self.detail) • active \(lastActivityText)"
|
||||
}
|
||||
if let unavailableReason {
|
||||
return unavailableReason
|
||||
}
|
||||
return self.detail
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelFallbackMetadata {
|
||||
let label: String
|
||||
let detail: String
|
||||
let systemImage: String
|
||||
}
|
||||
|
||||
private struct SettingsChannelAccount: Identifiable {
|
||||
let id: String
|
||||
let name: String?
|
||||
let configured: Bool
|
||||
let enabled: Bool
|
||||
let running: Bool
|
||||
let connected: Bool
|
||||
let linked: Bool
|
||||
let healthState: String?
|
||||
let lastError: String?
|
||||
let lastActivityMs: Int?
|
||||
|
||||
var displayName: String {
|
||||
let trimmedName = self.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmedName.isEmpty ? self.id : "\(trimmedName) (\(self.id))"
|
||||
}
|
||||
|
||||
var detailText: String {
|
||||
let state = if self.connected {
|
||||
"connected"
|
||||
} else if self.running {
|
||||
"running"
|
||||
} else if self.linked {
|
||||
"linked"
|
||||
} else if self.configured {
|
||||
"configured"
|
||||
} else {
|
||||
"not configured"
|
||||
}
|
||||
let enabledText = self.enabled ? "enabled" : "disabled"
|
||||
if let healthState, !healthState.isEmpty {
|
||||
return "\(state), \(enabledText), \(healthState)"
|
||||
}
|
||||
if let lastError, !lastError.isEmpty {
|
||||
return "\(state), \(enabledText), error"
|
||||
}
|
||||
return "\(state), \(enabledText)"
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
if self.connected || self.running { return OpenClawBrand.ok }
|
||||
if self.lastError != nil { return OpenClawBrand.warn }
|
||||
return self.configured ? OpenClawBrand.accent : .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsChannelOperation: Equatable {
|
||||
enum Kind {
|
||||
case start
|
||||
case stop
|
||||
case logout
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
let channelID: String
|
||||
let accountID: String?
|
||||
}
|
||||
|
||||
private enum SettingsChannelError: Error {
|
||||
case invalidPayload
|
||||
|
||||
var message: String {
|
||||
switch self {
|
||||
case .invalidPayload:
|
||||
"Could not encode channel request."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Channels states") {
|
||||
SettingsChannelsStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsChannelsStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
SettingsChannelRow(
|
||||
entry: Self.telegramEntry,
|
||||
canAdmin: true,
|
||||
busyOperation: nil,
|
||||
start: { _ in },
|
||||
stop: { _ in },
|
||||
logout: { _ in })
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "Loading",
|
||||
actionIcon: "hourglass",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
isActionDisabled: true,
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "hourglass",
|
||||
title: "Loading channel status",
|
||||
detail: "Checking installed channel clients and account state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
ProPanelHeader(
|
||||
title: "Message Routing",
|
||||
value: "0",
|
||||
actionIcon: "arrow.clockwise",
|
||||
actionAccessibilityLabel: "Refresh Channels",
|
||||
action: {})
|
||||
ProStatusRow(
|
||||
icon: "tray",
|
||||
title: "No channel plugins reported",
|
||||
detail: "Install or enable channel plugins on the gateway, then refresh.",
|
||||
value: "empty",
|
||||
color: .secondary)
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
ProStatusRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Channel status unavailable",
|
||||
detail: "Gateway returned an unexpected channel status response.",
|
||||
value: "error",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
|
||||
self.stateSection("Offline") {
|
||||
ProStatusRow(
|
||||
icon: "wifi.slash",
|
||||
title: "Gateway offline",
|
||||
detail: "Connect to the gateway to load installed channels, accounts, and routing status.",
|
||||
value: "offline",
|
||||
color: .secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static let telegramEntry = SettingsChannelEntry(
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
detail: "Message routing client",
|
||||
systemImage: "paperplane",
|
||||
configured: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
lastActivityText: "4m ago",
|
||||
lastError: nil,
|
||||
unavailableReason: nil,
|
||||
accounts: [
|
||||
SettingsChannelAccount(
|
||||
id: "main",
|
||||
name: "OpenClaw Ops",
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: true,
|
||||
connected: true,
|
||||
linked: true,
|
||||
healthState: "healthy",
|
||||
lastError: nil,
|
||||
lastActivityMs: nil),
|
||||
])
|
||||
}
|
||||
#endif
|
||||
@@ -58,38 +58,9 @@ struct SettingsProTab: View {
|
||||
@State var diagnosticsLastRunText = "Not run"
|
||||
@State var diagnosticsIssueCount: Int?
|
||||
@State var showTalkIssueDetails = false
|
||||
@State private var navigationPath: [SettingsRoute] = []
|
||||
let initialRoute: SettingsRoute?
|
||||
let directRoute: SettingsRoute?
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
|
||||
init(
|
||||
initialRoute: SettingsRoute? = nil,
|
||||
directRoute: SettingsRoute? = nil,
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil)
|
||||
{
|
||||
self.initialRoute = initialRoute
|
||||
self.directRoute = directRoute
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
self.settingsModalPresentation(
|
||||
self.settingsLifecycle(
|
||||
self.settingsContent))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var settingsContent: some View {
|
||||
if let directRoute {
|
||||
self.destination(for: directRoute)
|
||||
} else {
|
||||
self.settingsNavigationStack
|
||||
}
|
||||
}
|
||||
|
||||
private var settingsNavigationStack: some View {
|
||||
NavigationStack(path: self.$navigationPath) {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
@@ -107,17 +78,11 @@ struct SettingsProTab: View {
|
||||
.navigationDestination(for: SettingsRoute.self) { route in
|
||||
self.destination(for: route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsLifecycle(_ content: some View) -> some View {
|
||||
content
|
||||
.task {
|
||||
self.previousLocationModeRaw = self.locationModeRaw
|
||||
self.syncSettingsState()
|
||||
self.refreshNotificationSettings()
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
self.applyInitialRouteIfNeeded()
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
@@ -154,76 +119,66 @@ struct SettingsProTab: View {
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.applyPendingGatewaySetupLinkIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
private func settingsModalPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.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.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
problem: gatewayProblem,
|
||||
primaryActionTitle: self.gatewayProblemPrimaryActionTitle(gatewayProblem),
|
||||
onPrimaryAction: {
|
||||
Task { await self.handleGatewayProblemPrimaryAction(gatewayProblem) }
|
||||
})
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(issue: issue)
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
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 }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedGatewayLink(link)
|
||||
},
|
||||
onSetupCode: { code in
|
||||
self.handleScannedSetupCode(code)
|
||||
},
|
||||
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("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
.alert(
|
||||
"QR Scanner Unavailable",
|
||||
isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }))
|
||||
{
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyInitialRouteIfNeeded() {
|
||||
guard self.directRoute == nil else { return }
|
||||
guard let initialRoute else { return }
|
||||
guard self.navigationPath != [initialRoute] else { return }
|
||||
self.navigationPath = [initialRoute]
|
||||
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 ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,6 @@ extension SettingsProTab {
|
||||
case .gateway: "Gateway"
|
||||
case .approvals: "Approvals"
|
||||
case .permissions: "Permissions"
|
||||
case .channels: "Channels"
|
||||
case .voice: "Voice & Talk"
|
||||
case .diagnostics: "Diagnostics"
|
||||
case .privacy: "Privacy"
|
||||
@@ -504,20 +503,6 @@ extension SettingsProTab {
|
||||
}
|
||||
}
|
||||
|
||||
func subtitle(for route: SettingsRoute) -> String {
|
||||
switch route {
|
||||
case .gateway: "Pairing, diagnostics, and Tailscale checks."
|
||||
case .approvals: "Review pending agent actions."
|
||||
case .permissions: "Control device capabilities."
|
||||
case .channels: "Message routing and external clients."
|
||||
case .voice: "Talk mode and wake phrase settings."
|
||||
case .diagnostics: "Run local health checks."
|
||||
case .privacy: "Data and device privacy controls."
|
||||
case .notifications: "Alert permissions and delivery."
|
||||
case .about: "Version and support details."
|
||||
}
|
||||
}
|
||||
|
||||
var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
|
||||
@@ -3,20 +3,10 @@ import SwiftUI
|
||||
|
||||
extension SettingsProTab {
|
||||
var settingsHeader: some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: "Settings",
|
||||
subtitle: "Gateway, permissions, voice, and device controls.",
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var appearanceSection: some View {
|
||||
@@ -141,11 +131,6 @@ extension SettingsProTab {
|
||||
title: "Permissions",
|
||||
detail: self.permissionsDetail,
|
||||
route: .permissions)
|
||||
self.settingsListRow(
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
title: "Channels / Integrations",
|
||||
detail: "Message routing and external channel clients.",
|
||||
route: .channels)
|
||||
self.settingsListRow(
|
||||
icon: "waveform",
|
||||
title: "Voice & Talk",
|
||||
@@ -214,9 +199,6 @@ extension SettingsProTab {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if self.headerLeadingAction != nil {
|
||||
self.routeHeader(for: route)
|
||||
}
|
||||
switch route {
|
||||
case .gateway:
|
||||
self.gatewayDestination
|
||||
@@ -224,8 +206,6 @@ extension SettingsProTab {
|
||||
self.approvalsDestination
|
||||
case .permissions:
|
||||
self.permissionsDestination
|
||||
case .channels:
|
||||
SettingsChannelsDestination()
|
||||
case .voice:
|
||||
self.voiceDestination
|
||||
case .diagnostics:
|
||||
@@ -244,24 +224,6 @@ extension SettingsProTab {
|
||||
}
|
||||
.navigationTitle(self.title(for: route))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar(self.headerLeadingAction == nil ? .visible : .hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
func routeHeader(for route: SettingsRoute) -> some View {
|
||||
OpenClawAdaptiveHeaderRow(
|
||||
title: self.title(for: route),
|
||||
subtitle: self.subtitle(for: route),
|
||||
titleFont: .title3.weight(.semibold),
|
||||
subtitleFont: .callout)
|
||||
{
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
} accessory: {
|
||||
EmptyView()
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
var gatewayDestination: some View {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import Darwin
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsRoute: Hashable {
|
||||
case gateway
|
||||
case approvals
|
||||
case permissions
|
||||
case channels
|
||||
case voice
|
||||
case diagnostics
|
||||
case privacy
|
||||
@@ -152,176 +150,3 @@ extension SettingsProTab {
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview("Gateway settings states") {
|
||||
SettingsGatewayStatesPreview()
|
||||
}
|
||||
|
||||
private struct SettingsGatewayStatesPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
OpenClawProBackground()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.stateSection("Connected") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Gateway online",
|
||||
detail: "Connected to openclaw-gateway.tailnet.ts.net.",
|
||||
value: "online",
|
||||
color: OpenClawBrand.ok)
|
||||
self.gatewayFactsCard(
|
||||
address: "100.88.41.20:18789",
|
||||
server: "openclaw-gateway",
|
||||
discovered: "3",
|
||||
agent: "Aiden")
|
||||
}
|
||||
|
||||
self.stateSection("Loading") {
|
||||
self.gatewayStatusCard(
|
||||
title: "Checking gateway",
|
||||
detail: "Refreshing connection, discovery, and device trust state.",
|
||||
value: "loading",
|
||||
color: OpenClawBrand.accent)
|
||||
self.gatewayActionsCard(isBusy: true)
|
||||
}
|
||||
|
||||
self.stateSection("Empty") {
|
||||
self.gatewayStatusCard(
|
||||
title: "No gateway configured",
|
||||
detail: "Scan a setup QR code, paste a setup code, or choose a discovered gateway.",
|
||||
value: "setup",
|
||||
color: .secondary)
|
||||
self.setupActionsCard
|
||||
}
|
||||
|
||||
self.stateSection("Error") {
|
||||
GatewayProblemBanner(
|
||||
problem: Self.pairingProblem,
|
||||
primaryActionTitle: "Retry",
|
||||
onPrimaryAction: {},
|
||||
onShowDetails: {})
|
||||
self.gatewayStatusCard(
|
||||
title: "Tailscale warning",
|
||||
detail: "Tailscale is off on this device. Turn it on, then try again.",
|
||||
value: "network",
|
||||
color: OpenClawBrand.warn)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stateSection(
|
||||
_ title: String,
|
||||
@ViewBuilder content: () -> some View) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayStatusCard(
|
||||
title: String,
|
||||
detail: String,
|
||||
value: String,
|
||||
color: Color) -> some View
|
||||
{
|
||||
ProCard(padding: 0, radius: SettingsLayout.cardRadius) {
|
||||
ProStatusRow(
|
||||
icon: value == "online" ? "antenna.radiowaves.left.and.right" : "wifi.slash",
|
||||
title: title,
|
||||
detail: detail,
|
||||
value: value,
|
||||
color: color,
|
||||
actionTitle: value == "setup" ? "Scan QR" : nil,
|
||||
action: value == "setup" ? {} : nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayFactsCard(
|
||||
address: String,
|
||||
server: String,
|
||||
discovered: String,
|
||||
agent: String) -> some View
|
||||
{
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(spacing: 0) {
|
||||
self.factRow("Address", value: address)
|
||||
Divider()
|
||||
self.factRow("Server", value: server)
|
||||
Divider()
|
||||
self.factRow("Discovered", value: discovered)
|
||||
Divider()
|
||||
self.factRow("Default Agent", value: agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func factRow(_ label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text(value)
|
||||
.font(.caption.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.frame(height: SettingsLayout.rowHeight)
|
||||
}
|
||||
|
||||
private func gatewayActionsCard(isBusy: Bool) -> some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Reconnect", systemImage: "arrow.triangle.2.circlepath", isBusy: isBusy)
|
||||
self.previewButton("Diagnose", systemImage: "cross.case", isBusy: isBusy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var setupActionsCard: some View {
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
self.previewButton("Scan QR", systemImage: "qrcode.viewfinder", isBusy: false)
|
||||
self.previewButton("Connect", systemImage: "link", isBusy: false)
|
||||
}
|
||||
Text("Discovered gateways and manual setup live here when the gateway has not connected yet.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func previewButton(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
isBusy: Bool) -> some View
|
||||
{
|
||||
Button {} label: {
|
||||
Label(title, systemImage: systemImage)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(isBusy)
|
||||
}
|
||||
|
||||
private static let pairingProblem = GatewayConnectionProblem(
|
||||
kind: .pairingRequired,
|
||||
owner: .gateway,
|
||||
title: "Pairing required",
|
||||
message: "Run /pair approve in your OpenClaw chat before this iPad can connect.",
|
||||
actionCommand: "/pair approve req-ipad-preview",
|
||||
requestId: "req-ipad-preview",
|
||||
retryable: false,
|
||||
pauseReconnect: true)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,17 +9,8 @@ struct TalkProTab: View {
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@State private var showPermissionPrompt = false
|
||||
@State private var showTalkIssueDetails = false
|
||||
let headerLeadingAction: OpenClawSidebarHeaderAction?
|
||||
var openSettings: () -> Void
|
||||
|
||||
init(
|
||||
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
|
||||
openSettings: @escaping () -> Void)
|
||||
{
|
||||
self.headerLeadingAction = headerLeadingAction
|
||||
self.openSettings = openSettings
|
||||
}
|
||||
|
||||
private var state: TalkProState {
|
||||
TalkProState(
|
||||
gatewayConnected: self.gatewayConnected,
|
||||
@@ -94,9 +85,6 @@ struct TalkProTab: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .center, spacing: 11) {
|
||||
if let headerLeadingAction {
|
||||
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
|
||||
}
|
||||
OpenClawProMark(size: 31, shadowRadius: 9)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Talk")
|
||||
|
||||
@@ -116,8 +116,6 @@ final class NodeAppModel {
|
||||
self.operatorConnected
|
||||
}
|
||||
|
||||
private(set) var hasOperatorAdminScope: Bool = false
|
||||
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
@@ -299,7 +297,6 @@ final class NodeAppModel {
|
||||
let enabled = UserDefaults.standard.bool(forKey: "voiceWake.enabled")
|
||||
self.voiceWake.setEnabled(enabled)
|
||||
self.talkMode.attachGateway(self.operatorGateway)
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
self.refreshLastShareEventFromRelay()
|
||||
let talkEnabled = UserDefaults.standard.bool(forKey: "talk.enabled")
|
||||
self.setTalkEnabled(talkEnabled)
|
||||
@@ -2760,15 +2757,6 @@ extension NodeAppModel {
|
||||
private func setOperatorConnected(_ connected: Bool) {
|
||||
self.operatorConnected = connected
|
||||
self.operatorStatusText = connected ? "Connected" : "Offline"
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
}
|
||||
|
||||
private func refreshOperatorAdminScopeFromStore() {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
self.hasOperatorAdminScope = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
.scopes
|
||||
.contains("operator.admin") == true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4648,10 +4636,6 @@ extension NodeAppModel {
|
||||
self.gatewayConnected
|
||||
}
|
||||
|
||||
func _test_refreshOperatorAdminScopeFromStore() {
|
||||
self.refreshOperatorAdminScopeFromStore()
|
||||
}
|
||||
|
||||
func _test_applyPendingForegroundNodeActions(
|
||||
_ actions: [(id: String, command: String, paramsJSON: String?)]) async
|
||||
{
|
||||
|
||||
@@ -8,8 +8,6 @@ struct RootTabs: View {
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.rootTabsUserInterfaceIdiomOverride) private var userInterfaceIdiomOverride
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@@ -23,14 +21,10 @@ struct RootTabs: View {
|
||||
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
|
||||
AppAppearancePreference.system.rawValue
|
||||
@State private var selectedTab: AppTab = Self.initialTab
|
||||
@State private var selectedSidebarDestination: SidebarDestination = Self.initialSidebarDestination
|
||||
@State private var isSidebarVisible: Bool = Self.initialSidebarVisibility ?? false
|
||||
@State private var sidebarVisibilityUserOverridden: Bool = Self.initialSidebarVisibility != nil
|
||||
@State private var isSidebarDrawerLayout: Bool = false
|
||||
@State private var didResolveSidebarLayout: Bool = false
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
@State private var showGatewayProblemDetails: Bool = false
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@@ -40,6 +34,14 @@ struct RootTabs: View {
|
||||
@State private var didApplyInitialChatSession: Bool = false
|
||||
@State private var handledGatewaySetupRequestID: Int = 0
|
||||
|
||||
private enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
private static var initialTab: AppTab {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-tab") else {
|
||||
@@ -64,28 +66,6 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static var initialSidebarDestination: SidebarDestination {
|
||||
if let requested = requestedInitialSidebarDestination {
|
||||
return requested
|
||||
}
|
||||
return Self.defaultSidebarDestination(for: initialTab)
|
||||
}
|
||||
|
||||
private static var requestedInitialSidebarDestination: SidebarDestination? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-initial-destination") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
let requested = arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return SidebarDestination.allCases.first { $0.rawValue.lowercased() == requested }
|
||||
}
|
||||
|
||||
private static var initialSidebarVisibility: Bool? {
|
||||
requestedInitialSidebarVisibility(arguments: ProcessInfo.processInfo.arguments)
|
||||
}
|
||||
|
||||
private static var initialChatSessionKey: String? {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-chat-session") else {
|
||||
@@ -107,11 +87,45 @@ struct RootTabs: View {
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldUseSidebarTabs(
|
||||
idiom: UIUserInterfaceIdiom,
|
||||
horizontalSizeClass _: UserInterfaceSizeClass?) -> Bool
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
idiom == .pad
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -122,30 +136,20 @@ struct RootTabs: View {
|
||||
.tint(OpenClawBrand.accent))))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tabContent: some View {
|
||||
if self.usesSidebarTabs {
|
||||
self.sidebarSplitContent
|
||||
} else {
|
||||
self.phoneTabContent
|
||||
}
|
||||
}
|
||||
|
||||
private var phoneTabContent: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
RootTabsPhoneControlHub(
|
||||
groups: Self.phoneControlGroups,
|
||||
initialDestination: Self.requestedInitialSidebarDestination,
|
||||
openRootDestination: { self.selectSidebarDestination($0) })
|
||||
.tabItem { Label("Control", systemImage: "square.grid.2x2") }
|
||||
CommandCenterTab(
|
||||
openChat: { self.selectedTab = .chat },
|
||||
openSettings: { self.selectedTab = .settings })
|
||||
.tabItem { Label("Command", systemImage: "target") }
|
||||
.badge(self.appModel.pendingExecApprovalPrompt == nil ? 0 : 1)
|
||||
.tag(AppTab.control)
|
||||
|
||||
ChatProTab(openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
ChatProTab()
|
||||
.tabItem { Label("Chat", systemImage: "bubble.left.fill") }
|
||||
.tag(AppTab.chat)
|
||||
|
||||
TalkProTab(openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
TalkProTab(openSettings: { self.selectedTab = .settings })
|
||||
.tabItem {
|
||||
Label(
|
||||
"Talk",
|
||||
@@ -153,394 +157,16 @@ struct RootTabs: View {
|
||||
}
|
||||
.tag(AppTab.talk)
|
||||
|
||||
NavigationStack {
|
||||
AgentProTab(
|
||||
directRoute: .agents,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
}
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
AgentProTab()
|
||||
.tabItem { Label("Agent", systemImage: "person.2.fill") }
|
||||
.tag(AppTab.agent)
|
||||
|
||||
SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)
|
||||
.id(self.selectedSidebarDestination.settingsRoute.map { "\($0)" } ?? "settings")
|
||||
SettingsProTab()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape.fill") }
|
||||
.tag(AppTab.settings)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarSplitContent: some View {
|
||||
GeometryReader { proxy in
|
||||
let isDrawerLayout = self.shouldUseSidebarDrawer(containerSize: proxy.size)
|
||||
let sidebarWidth = self.sidebarWidth(containerWidth: proxy.size.width, isDrawerLayout: isDrawerLayout)
|
||||
Group {
|
||||
if isDrawerLayout {
|
||||
self.sidebarDrawerContent(sidebarWidth: sidebarWidth)
|
||||
} else {
|
||||
self.sidebarNavigationSplitContent(sidebarWidth: sidebarWidth)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: self.isSidebarVisible)
|
||||
.onAppear {
|
||||
self.updateSidebarLayout(containerSize: proxy.size, force: false)
|
||||
}
|
||||
.onChange(of: proxy.size) { _, size in
|
||||
self.updateSidebarLayout(containerSize: size, force: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
if self.isSidebarVisible {
|
||||
self.sidebarColumn
|
||||
.frame(width: sidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
self.sidebarVerticalSeparator
|
||||
}
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.background(OpenClawProBackground())
|
||||
}
|
||||
|
||||
private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
self.sidebarDetailNavigationShell
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if self.isSidebarVisible {
|
||||
Color.black.opacity(0.28)
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
self.hideSidebar()
|
||||
}
|
||||
.transition(.opacity)
|
||||
|
||||
self.sidebarColumn
|
||||
.frame(width: sidebarWidth, alignment: .topLeading)
|
||||
.frame(maxHeight: .infinity, alignment: .topLeading)
|
||||
.overlay(alignment: .trailing) {
|
||||
self.sidebarVerticalSeparator
|
||||
}
|
||||
.shadow(color: .black.opacity(0.26), radius: 18, x: 8, y: 0)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDetailShell: some View {
|
||||
self.sidebarDetail
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
}
|
||||
|
||||
private var sidebarColumn: some View {
|
||||
VStack(spacing: 0) {
|
||||
self.sidebarIdentityHeader
|
||||
self.sidebarList
|
||||
self.sidebarFooter
|
||||
}
|
||||
.safeAreaPadding(.top, 8)
|
||||
.safeAreaPadding(.bottom, 8)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private var sidebarIdentityHeader: some View {
|
||||
HStack(spacing: 10) {
|
||||
OpenClawProMark(size: 30, shadowRadius: 3)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("OpenClaw")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "circle.fill")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(self.sidebarGatewayStatusColor)
|
||||
Text(self.sidebarGatewayStatusTitle)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if self.isSidebarDrawerLayout {
|
||||
self.sidebarHideButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
.overlay(alignment: .bottom) {
|
||||
self.sidebarHorizontalSeparator
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("OpenClaw \(self.sidebarGatewayStatusTitle)")
|
||||
}
|
||||
|
||||
private var sidebarGatewayStatusTitle: String {
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
"Online"
|
||||
case .connecting:
|
||||
"Connecting"
|
||||
case .error:
|
||||
"Needs attention"
|
||||
case .disconnected:
|
||||
"Offline"
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarList: some View {
|
||||
List {
|
||||
ForEach(Self.sidebarGroups) { group in
|
||||
Section(group.title.capitalized) {
|
||||
ForEach(group.destinations) { destination in
|
||||
self.sidebarDestinationButton(destination)
|
||||
}
|
||||
}
|
||||
.listSectionSeparator(.hidden, edges: .all)
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.tint(OpenClawBrand.accent)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color(uiColor: .systemBackground))
|
||||
}
|
||||
|
||||
private var sidebarFooter: some View {
|
||||
VStack(spacing: 0) {
|
||||
self.sidebarHorizontalSeparator
|
||||
HStack(spacing: 10) {
|
||||
Text("VERSION")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer(minLength: 8)
|
||||
Text("v\(DeviceInfoHelper.openClawVersionString())")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
ProStatusDot(color: self.sidebarGatewayStatusColor)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarHorizontalSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(height: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
private var sidebarVerticalSeparator: some View {
|
||||
Rectangle()
|
||||
.fill(Color(uiColor: .separator))
|
||||
.frame(width: 1 / UIScreen.main.scale)
|
||||
}
|
||||
|
||||
private var sidebarGatewayStatusColor: Color {
|
||||
switch self.gatewayStatus {
|
||||
case .connected:
|
||||
OpenClawBrand.ok
|
||||
case .connecting:
|
||||
OpenClawBrand.accent
|
||||
case .error:
|
||||
OpenClawBrand.warn
|
||||
case .disconnected:
|
||||
.secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func sidebarDestinationButton(
|
||||
_ destination: SidebarDestination,
|
||||
title: String? = nil) -> some View
|
||||
{
|
||||
Button {
|
||||
self.selectSidebarDestination(destination)
|
||||
} label: {
|
||||
Label(title ?? destination.sidebarTitle, systemImage: destination.systemImage)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(destination == self.selectedSidebarDestination ? OpenClawBrand.accent : .primary)
|
||||
.listRowBackground(
|
||||
destination == self.selectedSidebarDestination
|
||||
? OpenClawBrand.accent.opacity(0.12)
|
||||
: Color.clear)
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sidebarDetail: some View {
|
||||
switch self.selectedSidebarDestination {
|
||||
case .chat:
|
||||
ChatProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .talk:
|
||||
TalkProTab(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .overview:
|
||||
CommandCenterTab(
|
||||
headerTitle: "Overview",
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
showsHeaderMark: false,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .activity:
|
||||
IPadActivityScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .workboard:
|
||||
IPadWorkboardScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) },
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .skillWorkshop:
|
||||
IPadSkillWorkshopScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
case .agents:
|
||||
AgentProTab(
|
||||
directRoute: .agents,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Agents",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .instances:
|
||||
AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Instances",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .sessions:
|
||||
CommandSessionsScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
openChat: { self.selectSidebarDestination(.chat) })
|
||||
case .dreaming:
|
||||
AgentProTab(
|
||||
directRoute: .dreaming,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Dreaming",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .usage:
|
||||
AgentProTab(
|
||||
directRoute: .usage,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Usage",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .cron:
|
||||
AgentProTab(
|
||||
directRoute: .cron,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
headerTitle: "Cron Jobs",
|
||||
openSettings: { self.selectSidebarDestination(.gateway) })
|
||||
.id(self.selectedSidebarDestination.id)
|
||||
case .docs:
|
||||
OpenClawDocsScreen(
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction,
|
||||
gatewayAction: { self.selectSidebarDestination(.gateway) })
|
||||
case .settings:
|
||||
SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
case .gateway:
|
||||
SettingsProTab(
|
||||
directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway,
|
||||
headerLeadingAction: self.sidebarHeaderLeadingAction)
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarDetailNavigationShell: some View {
|
||||
NavigationStack {
|
||||
self.sidebarDetailShell
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private var usesSidebarTabs: Bool {
|
||||
Self.shouldUseSidebarTabs(
|
||||
idiom: self.userInterfaceIdiom,
|
||||
horizontalSizeClass: self.horizontalSizeClass)
|
||||
}
|
||||
|
||||
private var userInterfaceIdiom: UIUserInterfaceIdiom {
|
||||
if let userInterfaceIdiomOverride {
|
||||
return userInterfaceIdiomOverride
|
||||
}
|
||||
return UIDevice.current.userInterfaceIdiom
|
||||
}
|
||||
|
||||
private var shouldCollapseSidebarAfterSelection: Bool {
|
||||
Self.shouldCollapseSidebarAfterSelection(
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
}
|
||||
|
||||
private var sidebarHeaderLeadingAction: OpenClawSidebarHeaderAction? {
|
||||
guard Self.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: self.isSidebarVisible,
|
||||
layoutMode: self.isSidebarDrawerLayout ? .drawer : .split)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
if self.isSidebarVisible {
|
||||
return OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Hide Sidebar",
|
||||
accessibilityIdentifier: Self.sidebarHideButtonAccessibilityIdentifier,
|
||||
action: { self.hideSidebar() })
|
||||
}
|
||||
return OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
accessibilityIdentifier: Self.sidebarShowButtonAccessibilityIdentifier,
|
||||
action: { self.showSidebar() })
|
||||
}
|
||||
|
||||
private var sidebarHideButton: some View {
|
||||
Button {
|
||||
self.hideSidebar()
|
||||
} label: {
|
||||
Image(systemName: self.isSidebarDrawerLayout ? "xmark" : "sidebar.left")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(OpenClawBrand.accent)
|
||||
.accessibilityLabel("Hide Sidebar")
|
||||
.accessibilityIdentifier(Self.sidebarHideButtonAccessibilityIdentifier)
|
||||
}
|
||||
|
||||
private func shouldUseSidebarDrawer(containerSize: CGSize) -> Bool {
|
||||
Self.sidebarLayoutMode(containerSize: containerSize) == .drawer
|
||||
}
|
||||
|
||||
private func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
||||
Self.sidebarWidth(containerWidth: containerWidth, isDrawerLayout: isDrawerLayout)
|
||||
}
|
||||
|
||||
private func rootOverlays(_ content: some View) -> some View {
|
||||
content
|
||||
.overlay(alignment: .top) {
|
||||
@@ -569,7 +195,6 @@ struct RootTabs: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
if self.appModel.cameraFlashNonce != 0 {
|
||||
RootCameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
@@ -700,7 +325,7 @@ struct RootTabs: View {
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.selectSidebarDestination(.chat)
|
||||
self.selectedTab = .chat
|
||||
}
|
||||
.onChange(of: self.appModel.gatewaySetupRequestID) { _, _ in
|
||||
self.maybeOpenSettingsForGatewaySetup()
|
||||
@@ -709,6 +334,10 @@ struct RootTabs: View {
|
||||
|
||||
private func rootPresentation(_ content: some View) -> some View {
|
||||
content
|
||||
.gatewayActionsDialog(
|
||||
isPresented: self.$showGatewayActions,
|
||||
onDisconnect: { self.appModel.disconnectGateway() },
|
||||
onOpenSettings: { self.selectedTab = .settings })
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
@@ -875,50 +504,6 @@ struct RootTabs: View {
|
||||
self.normalized(agent.name) ?? agent.id
|
||||
}
|
||||
|
||||
private func selectSidebarDestination(_ destination: SidebarDestination) {
|
||||
self.selectedSidebarDestination = destination
|
||||
self.selectedTab = destination.appTab
|
||||
guard self.usesSidebarTabs, self.shouldCollapseSidebarAfterSelection else { return }
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func showSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func hideSidebar() {
|
||||
self.sidebarVisibilityUserOverridden = true
|
||||
withAnimation(.easeInOut(duration: 0.22)) {
|
||||
self.setSidebarVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSidebarLayout(containerSize: CGSize, force: Bool) {
|
||||
let layoutMode = Self.sidebarLayoutMode(containerSize: containerSize)
|
||||
let previousLayoutMode: SidebarLayoutMode = self.isSidebarDrawerLayout ? .drawer : .split
|
||||
let didResolvePreviousLayout = self.didResolveSidebarLayout
|
||||
let layoutModeDidChange = layoutMode != previousLayoutMode
|
||||
self.didResolveSidebarLayout = true
|
||||
self.isSidebarDrawerLayout = layoutMode == .drawer
|
||||
if layoutModeDidChange && didResolvePreviousLayout {
|
||||
self.sidebarVisibilityUserOverridden = false
|
||||
}
|
||||
guard force || !self.sidebarVisibilityUserOverridden else { return }
|
||||
|
||||
let preferredVisibility = Self.preferredSidebarVisibility(layoutMode: layoutMode)
|
||||
guard self.isSidebarVisible != preferredVisibility else { return }
|
||||
self.setSidebarVisible(preferredVisibility)
|
||||
}
|
||||
|
||||
private func setSidebarVisible(_ isVisible: Bool) {
|
||||
self.isSidebarVisible = isVisible
|
||||
}
|
||||
|
||||
private func homeCanvasBadge(for agent: AgentSummary) -> String {
|
||||
if let identity = agent.identity,
|
||||
let emoji = identity["emoji"]?.value as? String,
|
||||
@@ -953,7 +538,7 @@ struct RootTabs: View {
|
||||
} else if problem.retryable {
|
||||
Task { await self.gatewayController.connectLastKnown() }
|
||||
} else {
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -980,7 +565,7 @@ struct RootTabs: View {
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,7 +591,7 @@ struct RootTabs: View {
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
|
||||
private func maybeOpenSettingsForGatewaySetup() {
|
||||
@@ -1016,7 +601,7 @@ struct RootTabs: View {
|
||||
self.showOnboarding = false
|
||||
self.presentedSheet = nil
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.selectedTab = .settings
|
||||
}
|
||||
|
||||
private func applyInitialChatSessionIfNeeded() {
|
||||
@@ -1096,120 +681,3 @@ private struct RootCameraFlashOverlay: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var rootTabsUserInterfaceIdiomOverride: UIUserInterfaceIdiom?
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview(
|
||||
"Shell iPhone portrait",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone connected",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone, gatewayState: .connected)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone gateway error",
|
||||
traits: .fixedLayout(width: 393, height: 852),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone, gatewayState: .error)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPhone landscape",
|
||||
traits: .fixedLayout(width: 852, height: 393),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .phone)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad portrait drawer",
|
||||
traits: .fixedLayout(width: 1024, height: 1366),
|
||||
.portrait)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad landscape split",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .connected)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad connecting",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .connecting)
|
||||
}
|
||||
|
||||
#Preview(
|
||||
"Shell iPad gateway error",
|
||||
traits: .fixedLayout(width: 1366, height: 1024),
|
||||
.landscapeLeft)
|
||||
{
|
||||
RootTabsPreviewHost(idiom: .pad, gatewayState: .error)
|
||||
}
|
||||
|
||||
private struct RootTabsPreviewHost: View {
|
||||
@State private var appModel: NodeAppModel
|
||||
@State private var gatewayController: GatewayConnectionController
|
||||
private let idiom: UIUserInterfaceIdiom
|
||||
|
||||
init(idiom: UIUserInterfaceIdiom, gatewayState: RootTabsPreviewGatewayState = .offline) {
|
||||
let appModel = NodeAppModel()
|
||||
gatewayState.apply(to: appModel)
|
||||
self.idiom = idiom
|
||||
_appModel = State(initialValue: appModel)
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(appModel: appModel, startDiscovery: false))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
RootTabs()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
.environment(\.rootTabsUserInterfaceIdiomOverride, self.idiom)
|
||||
}
|
||||
}
|
||||
|
||||
private enum RootTabsPreviewGatewayState {
|
||||
case offline
|
||||
case connecting
|
||||
case connected
|
||||
case error
|
||||
|
||||
@MainActor
|
||||
func apply(to appModel: NodeAppModel) {
|
||||
switch self {
|
||||
case .offline:
|
||||
break
|
||||
case .connecting:
|
||||
appModel.gatewayStatusText = "Connecting..."
|
||||
case .connected:
|
||||
appModel.enterAppleReviewDemoMode()
|
||||
case .error:
|
||||
appModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension RootTabs {
|
||||
private static var sidebarPersistentWidthThreshold: CGFloat {
|
||||
980
|
||||
}
|
||||
|
||||
static let sidebarSplitMinimumWidth: CGFloat = 292
|
||||
static let sidebarSplitIdealWidth: CGFloat = 316
|
||||
static let sidebarSplitMaximumWidth: CGFloat = 340
|
||||
static let sidebarDrawerMaximumWidth: CGFloat = 340
|
||||
static let sidebarShowButtonAccessibilityIdentifier = "RootTabs.Sidebar.Show"
|
||||
static let sidebarHideButtonAccessibilityIdentifier = "RootTabs.Sidebar.Hide"
|
||||
|
||||
enum AppTab: Hashable {
|
||||
case control
|
||||
case chat
|
||||
case talk
|
||||
case agent
|
||||
case settings
|
||||
}
|
||||
|
||||
enum SidebarDestination: String, CaseIterable, Hashable, Identifiable {
|
||||
case chat
|
||||
case talk
|
||||
case overview
|
||||
case activity
|
||||
case agents
|
||||
case workboard
|
||||
case skillWorkshop
|
||||
case instances
|
||||
case sessions
|
||||
case dreaming
|
||||
case usage
|
||||
case cron
|
||||
case docs
|
||||
case settings
|
||||
case gateway
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chat: "Chat"
|
||||
case .talk: "Talk"
|
||||
case .overview: "Overview"
|
||||
case .activity: "Activity"
|
||||
case .agents: "Agents"
|
||||
case .workboard: "Workboard"
|
||||
case .skillWorkshop: "Skill Workshop"
|
||||
case .instances: "Instances"
|
||||
case .sessions: "Sessions"
|
||||
case .dreaming: "Dreaming"
|
||||
case .usage: "Usage"
|
||||
case .cron: "Cron Jobs"
|
||||
case .docs: "Docs"
|
||||
case .settings: "Settings"
|
||||
case .gateway: "Settings / Gateway"
|
||||
}
|
||||
}
|
||||
|
||||
var sidebarTitle: String {
|
||||
switch self {
|
||||
case .gateway: "Connection"
|
||||
default: self.title
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .chat: "Agent chat and recent work."
|
||||
case .talk: "Realtime voice and fallback controls."
|
||||
case .overview: "Status, entry points, health."
|
||||
case .activity: "Gateway, session, and device activity."
|
||||
case .agents: "Agent roster and readiness."
|
||||
case .workboard: "Agent work queue and session handoff."
|
||||
case .skillWorkshop: "Review and apply proposed skills."
|
||||
case .instances: "Latest presence from OpenClaw nodes."
|
||||
case .sessions: "Active sessions and defaults."
|
||||
case .dreaming: "Memory signals and background synthesis."
|
||||
case .usage: "API usage and costs."
|
||||
case .cron: "Wakeups and recurring runs."
|
||||
case .docs: "Reference docs and setup guides."
|
||||
case .settings: "Connection, permissions, channels, and app options."
|
||||
case .gateway: "Pairing, diagnostics, permissions, and device controls."
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .chat: "bubble.left"
|
||||
case .talk: "waveform.circle"
|
||||
case .overview: "chart.bar"
|
||||
case .activity: "waveform.path.ecg"
|
||||
case .agents: "person.2"
|
||||
case .workboard: "folder"
|
||||
case .skillWorkshop: "hammer"
|
||||
case .instances: "dot.radiowaves.left.and.right"
|
||||
case .sessions: "doc.text"
|
||||
case .dreaming: "moon.stars"
|
||||
case .usage: "chart.bar.xaxis"
|
||||
case .cron: "timer"
|
||||
case .docs: "book"
|
||||
case .settings: "gearshape"
|
||||
case .gateway: "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
var appTab: AppTab {
|
||||
switch self {
|
||||
case .chat:
|
||||
.chat
|
||||
case .talk:
|
||||
.talk
|
||||
case .agents:
|
||||
.agent
|
||||
case .settings, .gateway:
|
||||
.settings
|
||||
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
|
||||
.usage,
|
||||
.cron, .docs:
|
||||
.control
|
||||
}
|
||||
}
|
||||
|
||||
var settingsRoute: SettingsRoute? {
|
||||
switch self {
|
||||
case .gateway:
|
||||
.gateway
|
||||
case .chat, .talk, .overview, .activity, .agents, .workboard, .skillWorkshop, .instances, .sessions,
|
||||
.dreaming,
|
||||
.usage, .cron, .settings, .docs:
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarLayoutMode: Equatable {
|
||||
case drawer
|
||||
case split
|
||||
}
|
||||
|
||||
static func sidebarLayoutMode(containerSize: CGSize) -> SidebarLayoutMode {
|
||||
containerSize.width < self.sidebarPersistentWidthThreshold || containerSize.height > containerSize.width
|
||||
? .drawer
|
||||
: .split
|
||||
}
|
||||
|
||||
static func preferredSidebarVisibility(layoutMode: SidebarLayoutMode) -> Bool {
|
||||
layoutMode == .split
|
||||
}
|
||||
|
||||
static func shouldCollapseSidebarAfterSelection(layoutMode: SidebarLayoutMode) -> Bool {
|
||||
layoutMode == .drawer
|
||||
}
|
||||
|
||||
static func sidebarWidth(containerWidth: CGFloat, isDrawerLayout: Bool) -> CGFloat {
|
||||
if isDrawerLayout {
|
||||
return min(self.sidebarDrawerMaximumWidth, max(280, containerWidth * 0.86))
|
||||
}
|
||||
return min(self.sidebarSplitMaximumWidth, max(self.sidebarSplitIdealWidth, containerWidth * 0.25))
|
||||
}
|
||||
|
||||
static func shouldShowSidebarRevealControl(isSidebarVisible: Bool) -> Bool {
|
||||
!isSidebarVisible
|
||||
}
|
||||
|
||||
static func shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: Bool,
|
||||
layoutMode: SidebarLayoutMode) -> Bool
|
||||
{
|
||||
switch layoutMode {
|
||||
case .split:
|
||||
true
|
||||
case .drawer:
|
||||
self.shouldShowSidebarRevealControl(isSidebarVisible: isSidebarVisible)
|
||||
}
|
||||
}
|
||||
|
||||
static func requestedInitialSidebarVisibility(arguments: [String]) -> Bool? {
|
||||
guard let flagIndex = arguments.firstIndex(of: "--openclaw-sidebar-visibility") else {
|
||||
return nil
|
||||
}
|
||||
let valueIndex = arguments.index(after: flagIndex)
|
||||
guard arguments.indices.contains(valueIndex) else { return nil }
|
||||
|
||||
switch arguments[valueIndex].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "visible", "show", "shown", "open", "true", "1":
|
||||
return true
|
||||
case "hidden", "hide", "closed", "false", "0":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldOpenRootTabFromPhoneHub(_ destination: SidebarDestination) -> Bool {
|
||||
switch destination {
|
||||
case .chat, .talk, .agents, .gateway, .settings:
|
||||
true
|
||||
case .overview, .activity, .workboard, .skillWorkshop, .instances, .sessions, .dreaming,
|
||||
.usage,
|
||||
.cron, .docs:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
static func defaultSidebarDestination(for tab: AppTab) -> SidebarDestination {
|
||||
switch tab {
|
||||
case .control:
|
||||
.overview
|
||||
case .chat:
|
||||
.chat
|
||||
case .talk:
|
||||
.talk
|
||||
case .agent:
|
||||
.agents
|
||||
case .settings:
|
||||
.settings
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
static func shouldPresentQuickSetup(
|
||||
quickSetupDismissed: Bool,
|
||||
showOnboarding: Bool,
|
||||
hasPresentedSheet: Bool,
|
||||
gatewayConnected: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
discoveredGatewayCount: Int) -> Bool
|
||||
{
|
||||
guard !quickSetupDismissed else { return false }
|
||||
guard !showOnboarding else { return false }
|
||||
guard !hasPresentedSheet else { return false }
|
||||
guard !gatewayConnected else { return false }
|
||||
guard !hasExistingGatewayConfig else { return false }
|
||||
return discoveredGatewayCount > 0
|
||||
}
|
||||
|
||||
struct SidebarGroup: Identifiable {
|
||||
let title: String
|
||||
let destinations: [SidebarDestination]
|
||||
|
||||
var id: String {
|
||||
self.title
|
||||
}
|
||||
}
|
||||
|
||||
static let sidebarGroups: [SidebarGroup] = [
|
||||
SidebarGroup(title: "CHAT", destinations: [.chat, .talk]),
|
||||
SidebarGroup(
|
||||
title: "CONTROL",
|
||||
destinations: [
|
||||
.overview,
|
||||
.activity,
|
||||
.agents,
|
||||
.workboard,
|
||||
.skillWorkshop,
|
||||
.instances,
|
||||
.sessions,
|
||||
.dreaming,
|
||||
.usage,
|
||||
.cron,
|
||||
]),
|
||||
SidebarGroup(
|
||||
title: "SETTINGS",
|
||||
destinations: [.settings]),
|
||||
SidebarGroup(title: "REFERENCE", destinations: [.docs]),
|
||||
]
|
||||
|
||||
static var phoneControlGroups: [SidebarGroup] {
|
||||
self.sidebarGroups
|
||||
.map { group in
|
||||
SidebarGroup(
|
||||
title: group.title,
|
||||
destinations: group.destinations.filter { $0 != .agents })
|
||||
}
|
||||
.filter { !$0.destinations.isEmpty }
|
||||
}
|
||||
}
|
||||
25
apps/ios/Sources/Status/GatewayActionsDialog.swift
Normal file
25
apps/ios/Sources/Status/GatewayActionsDialog.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
func gatewayActionsDialog(
|
||||
isPresented: Binding<Bool>,
|
||||
onDisconnect: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void) -> some View
|
||||
{
|
||||
self.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: isPresented,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
onDisconnect()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
onOpenSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,6 @@ Sources/Design/AgentProTab+Usage.swift
|
||||
Sources/Design/AgentProTab+DetailComponents.swift
|
||||
Sources/Design/AgentProTab+GatewayData.swift
|
||||
Sources/Design/AgentProModels.swift
|
||||
Sources/Design/IPadActivityScreen.swift
|
||||
Sources/Design/IPadSidebarFeaturePreviews.swift
|
||||
Sources/Design/IPadSidebarFeatureScreens.swift
|
||||
Sources/Design/IPadSkillWorkshopScreen.swift
|
||||
Sources/Design/IPadSidebarScreenChrome.swift
|
||||
Sources/Design/IPadWorkboardScreen.swift
|
||||
Sources/Design/OpenClawDocsScreen.swift
|
||||
Sources/Design/RootTabsPhoneControlHub.swift
|
||||
Sources/Design/SettingsChannelsDestination.swift
|
||||
Sources/EventKit/EventKitAuthorization.swift
|
||||
Sources/Gateway/DeepLinkAgentPromptAlert.swift
|
||||
Sources/Gateway/ExecApprovalPromptDialog.swift
|
||||
@@ -82,7 +73,6 @@ Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
Sources/Reminders/RemindersService.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/RootTabsNavigation.swift
|
||||
Sources/RootView.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
Sources/Screen/ScreenRecordService.swift
|
||||
@@ -96,6 +86,7 @@ Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
Sources/Status/GatewayActionsDialog.swift
|
||||
Sources/Status/GatewayStatusBuilder.swift
|
||||
Sources/Status/VoiceWakeToast.swift
|
||||
Sources/Voice/TalkGatewayPermissionState.swift
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@Suite struct CommandCenterTabLayoutTests {
|
||||
@Test func splitLayoutDisabledForCompactWidth() {
|
||||
#expect(
|
||||
!CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .compact,
|
||||
containerWidth: 1_200))
|
||||
}
|
||||
|
||||
@Test func splitLayoutDisabledBelowWidthThreshold() {
|
||||
#expect(
|
||||
!CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .regular,
|
||||
containerWidth: 900))
|
||||
}
|
||||
|
||||
@Test func splitLayoutEnabledForRegularWideLayout() {
|
||||
#expect(
|
||||
CommandCenterTab.usesSplitSectionsLayout(
|
||||
horizontalSizeClass: .regular,
|
||||
containerWidth: 1_024))
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,4 @@ import Testing
|
||||
|
||||
#expect(state == .connecting)
|
||||
}
|
||||
|
||||
@Test func chatGatewayPillLabelsMatchDisplayState() {
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .disconnected, isGatewayUsable: false) == "Offline")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connecting, isGatewayUsable: false) == "Connecting")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .error, isGatewayUsable: false) == "Attention")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: true) == "Connected")
|
||||
#expect(ChatProTab.gatewayPillTitle(state: .connected, isGatewayUsable: false) == "Unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,38 +1025,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel.openChatRequestID == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorAdminScopeCacheRefreshesFromStoredToken() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
#expect(appModel.hasOperatorAdminScope == false)
|
||||
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: "operator",
|
||||
token: "operator-token",
|
||||
scopes: ["operator.read", "operator.admin"])
|
||||
appModel._test_refreshOperatorAdminScopeFromStore()
|
||||
#expect(appModel.hasOperatorAdminScope == true)
|
||||
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: "operator")
|
||||
appModel._test_refreshOperatorAdminScopeFromStore()
|
||||
#expect(appModel.hasOperatorAdminScope == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func sendVoiceTranscriptThrowsWhenGatewayOffline() async {
|
||||
let appModel = NodeAppModel()
|
||||
await #expect(throws: Error.self) {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@MainActor
|
||||
@@ -40,432 +38,4 @@ import UIKit
|
||||
|
||||
#expect(!shouldPresent)
|
||||
}
|
||||
|
||||
@Test func sidebarTabsEnabledForIPadRegularWidth() {
|
||||
#expect(
|
||||
RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .pad,
|
||||
horizontalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func sidebarTabsEnabledForIPadCompactWidth() {
|
||||
#expect(
|
||||
RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .pad,
|
||||
horizontalSizeClass: .compact))
|
||||
}
|
||||
|
||||
@Test func sidebarTabsDisabledForIPhone() {
|
||||
#expect(
|
||||
!RootTabs.shouldUseSidebarTabs(
|
||||
idiom: .phone,
|
||||
horizontalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func sidebarGroupsMatchAdaptiveNavigationModel() {
|
||||
let groups = RootTabs.sidebarGroups
|
||||
let destinationIDs = RootTabs.SidebarDestination.allCases.map(\.rawValue)
|
||||
|
||||
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
|
||||
#expect(groups[0].destinations.map(\.rawValue) == ["chat", "talk"])
|
||||
#expect(groups[1].destinations == [
|
||||
.overview,
|
||||
.activity,
|
||||
.agents,
|
||||
.workboard,
|
||||
.skillWorkshop,
|
||||
.instances,
|
||||
.sessions,
|
||||
.dreaming,
|
||||
.usage,
|
||||
.cron,
|
||||
])
|
||||
#expect(groups[2].destinations == [.settings])
|
||||
#expect(groups[3].destinations == [.docs])
|
||||
#expect(destinationIDs == [
|
||||
"chat",
|
||||
"talk",
|
||||
"overview",
|
||||
"activity",
|
||||
"agents",
|
||||
"workboard",
|
||||
"skillWorkshop",
|
||||
"instances",
|
||||
"sessions",
|
||||
"dreaming",
|
||||
"usage",
|
||||
"cron",
|
||||
"docs",
|
||||
"settings",
|
||||
"gateway",
|
||||
])
|
||||
#expect(!destinationIDs.contains("agent"))
|
||||
#expect(!RootTabs.sidebarGroups.flatMap(\.destinations).contains(.gateway))
|
||||
}
|
||||
|
||||
@Test func phoneControlGroupsAvoidDuplicatingTheAgentTab() {
|
||||
let groups = RootTabs.phoneControlGroups
|
||||
let destinations = groups.flatMap(\.destinations)
|
||||
|
||||
#expect(groups.map(\.title) == ["CHAT", "CONTROL", "SETTINGS", "REFERENCE"])
|
||||
#expect(!destinations.contains(.agents))
|
||||
#expect(RootTabs.sidebarGroups.flatMap(\.destinations).contains(.agents))
|
||||
#expect(destinations.contains(.dreaming))
|
||||
#expect(destinations.contains(.instances))
|
||||
}
|
||||
|
||||
@Test func sidebarUsesCompactLabelsForLongRoutes() {
|
||||
#expect(RootTabs.SidebarDestination.settings.title == "Settings")
|
||||
#expect(RootTabs.SidebarDestination.gateway.title == "Settings / Gateway")
|
||||
#expect(RootTabs.SidebarDestination.gateway.sidebarTitle == "Connection")
|
||||
}
|
||||
|
||||
@Test func phoneHubUsesRootTabsOnlyForNativeChatAgentAndGateway() {
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.chat))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.talk))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.agents))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.gateway))
|
||||
#expect(RootTabs.shouldOpenRootTabFromPhoneHub(.settings))
|
||||
|
||||
for destination in RootTabs.SidebarDestination.allCases
|
||||
where destination != .chat && destination != .talk && destination != .agents && destination != .gateway && destination != .settings
|
||||
{
|
||||
#expect(!RootTabs.shouldOpenRootTabFromPhoneHub(destination))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func legacyInitialTabsMapToMatchingSidebarDestinations() {
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .control) == .overview)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .chat) == .chat)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .talk) == .talk)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .agent) == .agents)
|
||||
#expect(RootTabs.defaultSidebarDestination(for: .settings) == .settings)
|
||||
}
|
||||
|
||||
@Test func skillWorkshopMutationsRequireAdminScope() {
|
||||
#expect(IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: true))
|
||||
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: true, hasOperatorAdminScope: false))
|
||||
#expect(!IPadSkillWorkshopScreen.shouldEnableProposalMutation(canWrite: false, hasOperatorAdminScope: true))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopHeldFilterIncludesQuarantinedAndStale() {
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusFilters.contains("held"))
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "quarantined", filter: "held"))
|
||||
#expect(IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "stale", filter: "held"))
|
||||
#expect(!IPadSkillWorkshopScreen.proposalStatusMatchesFilter(status: "pending", filter: "held"))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopBoardLanesMatchStatusFilter() {
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "pending",
|
||||
proposalStatuses: ["pending", "applied"]) == ["pending"])
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "held",
|
||||
proposalStatuses: ["quarantined", "stale"]) == ["quarantined", "stale"])
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.proposalStatusBoardLanes(
|
||||
filter: "all",
|
||||
proposalStatuses: ["pending", "needs-review"]) == [
|
||||
"pending",
|
||||
"quarantined",
|
||||
"stale",
|
||||
"applied",
|
||||
"rejected",
|
||||
"needs-review",
|
||||
])
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("quarantined") == "Quarantined")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("pending") == "Pending")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("needs-review") == "Needs Review")
|
||||
#expect(IPadSkillWorkshopScreen.proposalLaneLabel("manual_QA") == "Manual QA")
|
||||
}
|
||||
|
||||
@Test func skillWorkshopSelectionStaysInsideActiveFilter() {
|
||||
let proposals = [
|
||||
(id: "applied-1", status: "applied"),
|
||||
(id: "pending-1", status: "pending"),
|
||||
(id: "held-1", status: "quarantined"),
|
||||
]
|
||||
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "applied-1",
|
||||
proposals: proposals,
|
||||
filter: "pending") == "pending-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "held-1",
|
||||
proposals: proposals,
|
||||
filter: "held") == "held-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "pending-1",
|
||||
visibleProposalIDs: ["held-1"]) == "held-1")
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.nextSelectedProposalID(
|
||||
current: "pending-1",
|
||||
visibleProposalIDs: []) == nil)
|
||||
}
|
||||
|
||||
@Test func workboardBoardScopeLabelsStayCompact() {
|
||||
#expect(IPadWorkboardScreen.normalizedScopeID(" planning ") == "planning")
|
||||
#expect(IPadWorkboardScreen.boardScopeLabel(for: "") == "All boards")
|
||||
#expect(IPadWorkboardScreen.boardScopeLabel(for: "planning") == "planning")
|
||||
#expect(IPadWorkboardScreen.boardScopeOptions(
|
||||
knownBoardIDs: ["default", " empty-board ", ""],
|
||||
cardBoardIDs: ["planning", "default"]) == ["default", "empty-board", "planning"])
|
||||
#expect(IPadWorkboardScreen
|
||||
.workboardSubtitle(boardScopeLabel: "All boards", selectedStatus: "active") == "All boards / Active")
|
||||
#expect(IPadWorkboardScreen
|
||||
.workboardSubtitle(boardScopeLabel: "planning", selectedStatus: "running") == "planning / Running")
|
||||
}
|
||||
|
||||
@Test func workboardCompactUnavailableCopyExplainsRealCapabilityState() {
|
||||
#expect(IPadWorkboardScreen
|
||||
.compactWriteUnavailableMessage(canRead: false) ==
|
||||
"Connect from Settings to create, move, and dispatch cards.")
|
||||
#expect(IPadWorkboardScreen.compactWriteUnavailableMessage(canRead: true) == "Read-only gateway.")
|
||||
}
|
||||
|
||||
@Test func skillWorkshopAgentScopeNormalizesGatewayIds() {
|
||||
#expect(IPadSkillWorkshopScreen.normalizedScopeID(" aiden ") == "aiden")
|
||||
#expect(IPadSkillWorkshopScreen.normalizedScopeID(nil) == "")
|
||||
}
|
||||
|
||||
@Test func channelLifecycleControlsRequireAdminScope() {
|
||||
#expect(SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: true))
|
||||
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: true, hasOperatorAdminScope: false))
|
||||
#expect(!SettingsChannelsDestination.shouldEnableChannelOperation(canRead: false, hasOperatorAdminScope: true))
|
||||
}
|
||||
|
||||
@Test func clickClackStaysInChannelsIntegrationMetadata() {
|
||||
#expect(SettingsChannelsDestination.fallbackLabel("clickclack") == "ClickClack")
|
||||
#expect(SettingsChannelsDestination.fallbackDetail("clickclack") == "Self-hosted chat bot routing.")
|
||||
#expect(SettingsChannelsDestination.fallbackSystemImage("clickclack") == "bubble.left.and.bubble.right")
|
||||
}
|
||||
|
||||
@Test func iPadOverviewCanSuppressStandaloneHeaderBranding() {
|
||||
#expect(CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: true))
|
||||
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: true, showsHeaderMark: true))
|
||||
#expect(!CommandCenterTab.shouldShowHeaderMark(hasLeadingAction: false, showsHeaderMark: false))
|
||||
}
|
||||
|
||||
@Test func chatSidebarDestinationCanUseRouteHeaderInsteadOfAgentBranding() {
|
||||
let standalone = ChatProTab()
|
||||
let routed = ChatProTab(
|
||||
headerTitle: "Chat",
|
||||
headerSubtitle: "Agent conversation",
|
||||
showsAgentBadge: false,
|
||||
openSettings: {})
|
||||
|
||||
#expect(standalone.showsAgentBadge)
|
||||
#expect(standalone.headerTitle == nil)
|
||||
#expect(standalone.openSettings == nil)
|
||||
#expect(routed.headerTitle == "Chat")
|
||||
#expect(routed.headerSubtitle == "Agent conversation")
|
||||
#expect(!routed.showsAgentBadge)
|
||||
#expect(routed.openSettings != nil)
|
||||
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: true, agentDisplayName: "OpenClaw") == "OpenClaw")
|
||||
#expect(ChatProTab.defaultHeaderTitle(showsAgentBadge: false, agentDisplayName: "OpenClaw") == "Chat")
|
||||
}
|
||||
|
||||
@Test func agentRoutesCanOpenGatewaySettingsFromHeaderPill() {
|
||||
let standalone = AgentProTab()
|
||||
let routed = AgentProTab(
|
||||
directRoute: .instances,
|
||||
headerTitle: "Instances",
|
||||
openSettings: {})
|
||||
|
||||
#expect(standalone.headerTitle == "Agents")
|
||||
#expect(standalone.directRoute == nil)
|
||||
#expect(standalone.openSettings == nil)
|
||||
#expect(AgentProTab(directRoute: .agents).directRoute == .agents)
|
||||
#expect(routed.directRoute == .instances)
|
||||
#expect(routed.headerTitle == "Instances")
|
||||
#expect(routed.openSettings != nil)
|
||||
}
|
||||
|
||||
@Test func workboardDispatchSummaryReportsStartedAndFailures() throws {
|
||||
let payload = Data(
|
||||
"""
|
||||
{
|
||||
"count": 2,
|
||||
"started": [{}],
|
||||
"startFailures": [{}],
|
||||
"promoted": [],
|
||||
"reclaimed": [],
|
||||
"blocked": [],
|
||||
"orchestrated": []
|
||||
}
|
||||
""".utf8)
|
||||
let summary = try JSONDecoder().decode(IPadWorkboardDispatchSummary.self, from: payload)
|
||||
|
||||
#expect(summary.summaryText == "2 dispatched: 1 started, 1 failed.")
|
||||
}
|
||||
|
||||
@Test func talkSidebarDestinationCanReceiveRevealAction() {
|
||||
let action = OpenClawSidebarHeaderAction(
|
||||
systemName: "sidebar.left",
|
||||
accessibilityLabel: "Show Sidebar",
|
||||
action: {})
|
||||
let routed = TalkProTab(headerLeadingAction: action, openSettings: {})
|
||||
|
||||
#expect(routed.headerLeadingAction?.systemName == "sidebar.left")
|
||||
#expect(routed.headerLeadingAction?.accessibilityLabel == "Show Sidebar")
|
||||
}
|
||||
|
||||
@Test func iPadPortraitUsesHiddenDrawerSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1024, height: 1366))
|
||||
|
||||
#expect(mode == .drawer)
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func iPadWideLandscapeUsesVisibleSplitSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 1366, height: 1024))
|
||||
|
||||
#expect(mode == .split)
|
||||
#expect(RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func iPadSplitSidebarWidthStaysUsable() {
|
||||
let width = RootTabs.sidebarWidth(containerWidth: 1366, isDrawerLayout: false)
|
||||
|
||||
#expect(width >= RootTabs.sidebarSplitIdealWidth)
|
||||
#expect(width <= RootTabs.sidebarSplitMaximumWidth)
|
||||
}
|
||||
|
||||
@Test func iPadCollapsedSplitSidebarUsesHeaderRevealWithoutReservedRail() {
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .drawer))
|
||||
}
|
||||
|
||||
@Test func initialSidebarVisibilityParsesLaunchArgument() {
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"hidden",
|
||||
]) == false)
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"visible",
|
||||
]) == true)
|
||||
#expect(
|
||||
RootTabs.requestedInitialSidebarVisibility(arguments: [
|
||||
"OpenClaw",
|
||||
"--openclaw-sidebar-visibility",
|
||||
"unknown",
|
||||
]) == nil)
|
||||
}
|
||||
|
||||
@Test func sidebarControlsHaveStableAccessibilityIdentifiers() {
|
||||
#expect(RootTabs.sidebarShowButtonAccessibilityIdentifier == "RootTabs.Sidebar.Show")
|
||||
#expect(RootTabs.sidebarHideButtonAccessibilityIdentifier == "RootTabs.Sidebar.Hide")
|
||||
}
|
||||
|
||||
@Test func iPadDrawerSidebarWidthStaysInsideScreen() {
|
||||
let width = RootTabs.sidebarWidth(containerWidth: 744, isDrawerLayout: true)
|
||||
|
||||
#expect(width >= 280)
|
||||
#expect(width <= RootTabs.sidebarDrawerMaximumWidth)
|
||||
}
|
||||
|
||||
@Test func narrowLandscapeKeepsDrawerSidebar() {
|
||||
let mode = RootTabs.sidebarLayoutMode(containerSize: CGSize(width: 900, height: 600))
|
||||
|
||||
#expect(mode == .drawer)
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: mode))
|
||||
}
|
||||
|
||||
@Test func drawerSelectionCollapsesSidebarButSplitSelectionDoesNot() {
|
||||
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
|
||||
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
|
||||
}
|
||||
|
||||
@Test func hiddenSidebarShowsRevealControl() {
|
||||
#expect(RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: false))
|
||||
}
|
||||
|
||||
@Test func sidebarRevealControlsHideWhenSidebarIsVisible() {
|
||||
#expect(!RootTabs.shouldShowSidebarRevealControl(isSidebarVisible: true))
|
||||
}
|
||||
|
||||
@Test func iPadSplitPrefersIntegratedVisibleSidebar() {
|
||||
#expect(RootTabs.preferredSidebarVisibility(layoutMode: .split))
|
||||
#expect(!RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .split))
|
||||
#expect(!RootTabs.preferredSidebarVisibility(layoutMode: .drawer))
|
||||
#expect(RootTabs.shouldCollapseSidebarAfterSelection(layoutMode: .drawer))
|
||||
}
|
||||
|
||||
@Test func destinationHeadersOwnHiddenSidebarRevealControl() {
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: false,
|
||||
layoutMode: .split))
|
||||
#expect(
|
||||
!RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .drawer))
|
||||
#expect(
|
||||
RootTabs.shouldShowSidebarRevealInDestinationHeader(
|
||||
isSidebarVisible: true,
|
||||
layoutMode: .split))
|
||||
}
|
||||
|
||||
@Test func workboardAndSkillWorkshopUseCompactTaskFlowOnPhoneSizes() {
|
||||
#expect(
|
||||
IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact))
|
||||
#expect(
|
||||
IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact))
|
||||
}
|
||||
|
||||
@Test func workboardAndSkillWorkshopKeepRegularTaskFlowOnWideIPadSizes() {
|
||||
#expect(
|
||||
!IPadWorkboardScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular))
|
||||
#expect(
|
||||
!IPadSkillWorkshopScreen.usesCompactTaskFlow(
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular))
|
||||
}
|
||||
|
||||
@Test func phoneHubLeavesRoomForFloatingTabBar() {
|
||||
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .regular) == 112)
|
||||
#expect(RootTabsPhoneControlHub.bottomScrollInset(verticalSizeClass: .compact) == 72)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSidebarRegressionTests {
|
||||
@Test func iPadSplitHiddenSidebarUsesHeaderRevealInsteadOfReservedRail() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
|
||||
|
||||
#expect(splitContent.contains("HStack(spacing: 0)"))
|
||||
#expect(splitContent.contains("self.sidebarColumn"))
|
||||
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
|
||||
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
|
||||
#expect(!splitContent.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(!source.contains("NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!splitContent.contains("NavigationSplitView"))
|
||||
#expect(!splitContent.contains("self.collapsedSidebarRail"))
|
||||
#expect(!source.contains("private var collapsedSidebarRail: some View"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
|
||||
#expect(!navigationSource.contains("static let sidebarCollapsedRailWidth"))
|
||||
#expect(!navigationSource.contains("static func sidebarSplitColumnVisibility(isSidebarVisible: Bool)"))
|
||||
#expect(!navigationSource
|
||||
.contains("static func sidebarIsVisible(splitColumnVisibility: NavigationSplitViewVisibility)"))
|
||||
}
|
||||
|
||||
@Test func initialSidebarVisibilitySurvivesFirstLayoutMeasurement() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let layoutUpdate = try Self.extract(
|
||||
source,
|
||||
from: "private func updateSidebarLayout(containerSize: CGSize, force: Bool)",
|
||||
to: "private func setSidebarVisible(_ isVisible: Bool)")
|
||||
|
||||
#expect(source.contains("@State private var didResolveSidebarLayout: Bool = false"))
|
||||
#expect(layoutUpdate.contains("let didResolvePreviousLayout = self.didResolveSidebarLayout"))
|
||||
#expect(layoutUpdate.contains("self.didResolveSidebarLayout = true"))
|
||||
#expect(layoutUpdate.contains("if layoutModeDidChange && didResolvePreviousLayout"))
|
||||
#expect(layoutUpdate.contains("guard force || !self.sidebarVisibilityUserOverridden else { return }"))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func rootTabsNavigationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabsNavigation.swift")
|
||||
}
|
||||
|
||||
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
|
||||
let startRange = try #require(source.range(of: start))
|
||||
let tail = source[startRange.lowerBound...]
|
||||
let endRange = try #require(tail.range(of: end))
|
||||
return String(tail[..<endRange.lowerBound])
|
||||
}
|
||||
}
|
||||
@@ -1,812 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite struct RootTabsSourceGuardTests {
|
||||
@Test func hiddenSidebarRevealUsesDestinationHeaderWithoutReservedRail() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let componentSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("sidebarHeaderLeadingAction"))
|
||||
#expect(source.contains("Hide Sidebar"))
|
||||
#expect(source.contains("Show Sidebar"))
|
||||
#expect(source.contains("shouldShowSidebarRevealInDestinationHeader"))
|
||||
#expect(source.contains("layoutMode: self.isSidebarDrawerLayout ? .drawer : .split"))
|
||||
#expect(componentSource.contains("OpenClawSidebarHeaderLeadingSlot"))
|
||||
#expect(componentSource.contains(".frame(width: 44, height: 44, alignment: .center)"))
|
||||
#expect(source.contains(".safeAreaPadding(.top, 8)"))
|
||||
#expect(source.contains("Self.sidebarShowButtonAccessibilityIdentifier"))
|
||||
#expect(source.contains("Self.sidebarHideButtonAccessibilityIdentifier"))
|
||||
#expect(source.contains("accessibilityLabel: \"Hide Sidebar\""))
|
||||
#expect(source.contains("accessibilityLabel: \"Show Sidebar\""))
|
||||
#expect(source.contains("action: { self.hideSidebar() }"))
|
||||
#expect(source.contains("action: { self.showSidebar() }"))
|
||||
#expect(!source.contains("private var collapsedSidebarRail: some View"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(source.contains("requestedInitialSidebarVisibility"))
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("NavigationSplitView(columnVisibility: self.$splitColumnVisibility)"))
|
||||
#expect(source.contains("HStack(spacing: 0)"))
|
||||
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(!source.contains("shouldReserveSidebarRevealInset"))
|
||||
#expect(!source.contains("safeAreaInset(edge: .top"))
|
||||
#expect(!source.contains("thinMaterial, in: Circle"))
|
||||
#expect(!source.contains("sidebarRevealInset"))
|
||||
#expect(source.contains("Color.black.opacity(0.28)"))
|
||||
#expect(source.contains(".background(Color(uiColor: .systemBackground))"))
|
||||
#expect(!source.contains("sidebarRevealCornerButton"))
|
||||
#expect(!source.contains("shouldShowSidebarRevealOverlay"))
|
||||
#expect(!source.contains("shouldShowOverviewHeaderSidebarReveal"))
|
||||
}
|
||||
|
||||
@Test func iPadSplitUsesSlidingSidebarWhilePortraitKeepsDrawerOverlay() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let splitContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarNavigationSplitContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View")
|
||||
let drawerContent = try Self.extract(
|
||||
source,
|
||||
from: "private func sidebarDrawerContent(sidebarWidth: CGFloat) -> some View",
|
||||
to: "private var sidebarDetailShell: some View")
|
||||
|
||||
#expect(!source.contains("@State private var splitColumnVisibility: NavigationSplitViewVisibility"))
|
||||
#expect(!source.contains("Self.sidebarSplitColumnVisibility(isSidebarVisible:"))
|
||||
#expect(!source.contains("self.syncSidebarVisibility(from: visibility)"))
|
||||
#expect(splitContent.contains("HStack(spacing: 0)"))
|
||||
#expect(splitContent.contains("self.sidebarColumn"))
|
||||
#expect(splitContent.contains(".frame(width: sidebarWidth, alignment: .topLeading)"))
|
||||
#expect(splitContent.contains(".overlay(alignment: .trailing)"))
|
||||
#expect(splitContent.contains("self.sidebarVerticalSeparator"))
|
||||
#expect(splitContent.contains("self.sidebarDetailNavigationShell"))
|
||||
#expect(!splitContent.contains("NavigationSplitView"))
|
||||
#expect(!splitContent.contains("self.collapsedSidebarRail"))
|
||||
#expect(!source.contains("Self.sidebarCollapsedRailWidth"))
|
||||
#expect(drawerContent.contains("ZStack(alignment: .topLeading)"))
|
||||
#expect(drawerContent.contains("Color.black.opacity(0.28)"))
|
||||
#expect(drawerContent.contains(".transition(.move(edge: .leading).combined(with: .opacity))"))
|
||||
#expect(!drawerContent.contains("NavigationSplitView"))
|
||||
}
|
||||
|
||||
@Test func sidebarKeepsNavigationModelDestinationOnly() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let navigationSource = try String(contentsOf: Self.rootTabsNavigationSourceURL(), encoding: .utf8)
|
||||
let sidebarColumn = try Self.extract(
|
||||
source,
|
||||
from: "private var sidebarColumn: some View",
|
||||
to: "private var sidebarList: some View")
|
||||
|
||||
#expect(source.contains("ForEach(Self.sidebarGroups)"))
|
||||
#expect(!source.contains("Section(\"Context\")"))
|
||||
#expect(!source.contains("sidebarAgentMenu"))
|
||||
#expect(!source.contains("sidebarDeviceMenu"))
|
||||
#expect(sidebarColumn.contains("self.sidebarIdentityHeader"))
|
||||
#expect(source.contains("private var sidebarIdentityHeader: some View"))
|
||||
#expect(source.contains("OpenClawProMark(size: 30"))
|
||||
#expect(source.contains("Text(\"OpenClaw\")"))
|
||||
#expect(source.contains("private var sidebarGatewayStatusTitle: String"))
|
||||
#expect(source.contains("private var sidebarGatewayStatusColor: Color"))
|
||||
#expect(!sidebarColumn.contains("activeAgent"))
|
||||
#expect(!source.contains("shouldShowSidebarColumnHeader"))
|
||||
#expect(!source.contains("private var sidebarColumnHeader: some View"))
|
||||
#expect(sidebarColumn.contains(".safeAreaPadding(.top, 8)"))
|
||||
#expect(source.contains(".scrollContentBackground(.hidden)"))
|
||||
#expect(source.contains(".listStyle(.sidebar)"))
|
||||
#expect(source.contains("private var sidebarHorizontalSeparator: some View"))
|
||||
#expect(source.contains("private var sidebarVerticalSeparator: some View"))
|
||||
#expect(source.contains("1 / UIScreen.main.scale"))
|
||||
#expect(!source.contains("geometry.size.height >= Self.sidebarListNonScrollingMinimumHeight"))
|
||||
#expect(!source.contains("private var sidebarListContent: some View"))
|
||||
#expect(source.contains(".listRowSeparator(.hidden, edges: .all)"))
|
||||
#expect(source.contains(".listSectionSeparator(.hidden, edges: .all)"))
|
||||
#expect(source.contains("if self.isSidebarDrawerLayout {"))
|
||||
#expect(source.contains("private var sidebarFooter: some View"))
|
||||
#expect(!source.contains("LabeledContent(\"Version\""))
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"CHAT\", destinations: [.chat, .talk])"))
|
||||
#expect(!navigationSource.contains("title: \"AGENT\""))
|
||||
#expect(navigationSource.contains("case settings"))
|
||||
#expect(!navigationSource.contains("case settingsChannels"))
|
||||
#expect(!navigationSource.contains("case settingsApprovals"))
|
||||
#expect(!navigationSource.contains("case settingsPrivacy"))
|
||||
#expect(navigationSource.contains("SidebarGroup(\n title: \"SETTINGS\""))
|
||||
#expect(navigationSource.contains("destinations: [.settings]"))
|
||||
#expect(!navigationSource.contains("destinations: [.gateway"))
|
||||
#expect(!navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.settings"))
|
||||
#expect(navigationSource.contains("SidebarGroup(title: \"REFERENCE\", destinations: [.docs])"))
|
||||
}
|
||||
|
||||
@Test func sidebarRoutesUseDestinationHeadersInsteadOfRepeatedProductBranding() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let sidebarDetail = try Self.extract(
|
||||
rootSource,
|
||||
from: "private var sidebarDetail: some View",
|
||||
to: "private var sidebarDetailNavigationShell: some View")
|
||||
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Chat\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Overview\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Agents\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Instances\""))
|
||||
#expect(!sidebarDetail.contains("headerTitle: \"Nodes\""))
|
||||
#expect(sidebarDetail.contains("directRoute: .agents"))
|
||||
#expect(sidebarDetail.contains("directRoute: .instances"))
|
||||
#expect(sidebarDetail.contains("directRoute: .dreaming"))
|
||||
#expect(sidebarDetail.contains("directRoute: .usage"))
|
||||
#expect(sidebarDetail.contains("directRoute: .cron"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .nodes"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .usage"))
|
||||
#expect(!sidebarDetail.contains("initialRoute: .cron"))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Dreaming\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Usage\""))
|
||||
#expect(sidebarDetail.contains("headerTitle: \"Cron Jobs\""))
|
||||
#expect(!sidebarDetail.contains("headerTitle: \"OpenClaw\""))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("title: self.headerTitle"))
|
||||
#expect(!agentOverviewSource.contains("Text(\"OpenClaw\")"))
|
||||
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(docsSource.contains("title: \"Docs\""))
|
||||
#expect(!docsSource.contains("Text(\"OpenClaw Docs\")"))
|
||||
}
|
||||
|
||||
@Test func agentsDirectRouteKeepsSingleSidebarControl() throws {
|
||||
let source = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
|
||||
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
|
||||
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
|
||||
let directDestination = try Self.extract(
|
||||
source,
|
||||
from: "private func directDestination(for route: AgentRoute) -> some View",
|
||||
to: "private func applyInitialRouteIfNeeded()")
|
||||
|
||||
#expect(!directDestination.contains("ToolbarItem"))
|
||||
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
|
||||
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
|
||||
#expect(destinationsSource.contains("self.directHeader(\n for: .cron"))
|
||||
#expect(destinationsSource.contains("self.directRoute == route ? self.headerLeadingAction : nil"))
|
||||
#expect(nodesSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
#expect(dreamingSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
}
|
||||
|
||||
@Test func routedHeadersUseSharedAdaptiveLayout() throws {
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let featureChromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(componentsSource.contains("struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View"))
|
||||
#expect(componentsSource.contains("ViewThatFits(in: .horizontal)"))
|
||||
#expect(componentsSource.contains("private var stackedLayout: some View"))
|
||||
#expect(componentsSource.contains(".layoutPriority(1)"))
|
||||
#expect(componentsSource.contains(".fixedSize(horizontal: true, vertical: false)"))
|
||||
#expect(featureChromeSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(docsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(overviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(chatSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(agentOverviewSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
#expect(settingsSource.contains("OpenClawAdaptiveHeaderRow("))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsDocsAsDestinationOnly() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("case .docs:"))
|
||||
#expect(source.contains("OpenClawDocsScreen("))
|
||||
#expect(source.contains("headerLeadingAction: self.phoneDetailBackAction"))
|
||||
#expect(source.contains("gatewayAction: { self.openRootDestination(.gateway) }"))
|
||||
#expect(!source.contains("Label(\"Docs\", systemImage: \"book\")"))
|
||||
#expect(!source.contains("https://docs.openclaw.ai"))
|
||||
}
|
||||
|
||||
@Test func rootShellPreviewMatrixCoversPhoneAndIPadStates() throws {
|
||||
let source = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone portrait\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone landscape\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone connected\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPhone gateway error\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad portrait drawer\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad landscape split\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad connecting\""))
|
||||
#expect(source.contains("#Preview(\n \"Shell iPad gateway error\""))
|
||||
}
|
||||
|
||||
@Test func sharedChatPreviewMatrixCoversConnectionStates() throws {
|
||||
let source = try String(contentsOf: Self.sharedChatPreviewSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Chat connected\")"))
|
||||
#expect(source.contains("#Preview(\"Chat empty\")"))
|
||||
#expect(source.contains("#Preview(\"Chat loading\")"))
|
||||
#expect(source.contains("#Preview(\"Chat gateway error\")"))
|
||||
#expect(source.contains("enum Scenario"))
|
||||
#expect(source.contains("case connected"))
|
||||
#expect(source.contains("case empty"))
|
||||
#expect(source.contains("case loading"))
|
||||
#expect(source.contains("case error"))
|
||||
#expect(source.contains("Gateway not connected. Check Tailscale and retry."))
|
||||
}
|
||||
|
||||
@Test func phoneHubKeepsContentAboveFloatingTabBar() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains(".safeAreaPadding(.bottom, self.bottomScrollInset)"))
|
||||
#expect(!source.contains(".padding(.bottom, self.bottomScrollInset)"))
|
||||
#expect(!source.contains("bottomViewportInset"))
|
||||
#expect(!source.contains("bottomTabBarClearance"))
|
||||
}
|
||||
|
||||
@Test func phoneHubHeaderStaysTaskFirst() throws {
|
||||
let source = try String(contentsOf: Self.phoneHubSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("private var gatewayActionRow: some View"))
|
||||
#expect(source.contains("self.openRootDestination(.gateway)"))
|
||||
#expect(source.contains("private var phoneDetailBackAction: OpenClawSidebarHeaderAction"))
|
||||
#expect(source.contains("accessibilityLabel: \"Back to Control\""))
|
||||
#expect(source.contains("accessibilityIdentifier: \"OpenClawPhoneDetailBackButton\""))
|
||||
#expect(source.contains(".navigationBarBackButtonHidden(true)"))
|
||||
#expect(source.contains(".toolbar(.hidden, for: .navigationBar)"))
|
||||
#expect(source.matches(of: /headerLeadingAction: self\.phoneDetailBackAction/).count == 10)
|
||||
#expect(!source.contains("directRoute: .agents"))
|
||||
#expect(!source.contains("ToolbarItem(placement: .topBarTrailing)"))
|
||||
#expect(!source.contains("Image(systemName: \"gearshape\")"))
|
||||
#expect(!source.contains("self.metric(label:"))
|
||||
#expect(!source.contains("private func metric(label:"))
|
||||
}
|
||||
|
||||
@Test func workboardUsesRealGatewayMethods() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("workboard.cards.list"))
|
||||
#expect(source.contains("workboard.cards.create"))
|
||||
#expect(source.contains("workboard.cards.move"))
|
||||
#expect(source.contains("workboard.cards.archive"))
|
||||
#expect(source.contains("workboard.cards.dispatch"))
|
||||
#expect(source.contains(".padding(.bottom, 12)"))
|
||||
#expect(!source.contains("Workboard gateway contract unavailable"))
|
||||
#expect(!source.contains("supportsGatewayContract"))
|
||||
#expect(!source.contains("Compact mobile queue control"))
|
||||
#expect(!source.contains("Multi-column queue control"))
|
||||
}
|
||||
|
||||
@Test func workboardCreateActionSurfacesUnavailableReasons() throws {
|
||||
let source = try String(contentsOf: Self.iPadWorkboardScreenSourceURL(), encoding: .utf8)
|
||||
let createFunction = try Self.extract(
|
||||
source,
|
||||
from: "private func createCard() async -> Bool",
|
||||
to: "private func move(_ card: IPadWorkboardCard, to status: String) async")
|
||||
|
||||
#expect(source.contains("private var createUnavailableMessage: String?"))
|
||||
#expect(source.contains("Enter a title to create a card."))
|
||||
#expect(source.contains("Card creation is already in progress."))
|
||||
#expect(source.contains("private func newCardButton(expands: Bool) -> some View"))
|
||||
#expect(source.contains("private func beginCreateCard()"))
|
||||
#expect(source.contains("self.newCardButton(expands: false)"))
|
||||
#expect(source.contains("self.newCardButton(expands: true)"))
|
||||
#expect(source.contains("Label(\"New Card\", systemImage: \"plus\")"))
|
||||
#expect(source.contains(".accessibilityHint(\"Opens card title and notes entry\")"))
|
||||
#expect(source.contains(".accessibilityHint(self.createUnavailableMessage ?? \"Creates a workboard card\")"))
|
||||
#expect(source.contains("if await self.createCard()"))
|
||||
#expect(source.contains(".disabled(self.isCreatingCard)"))
|
||||
#expect(!source.contains("Button(\"Create\")"))
|
||||
#expect(!source.contains("TextField(\"New card\""))
|
||||
#expect(!source.contains(".disabled(!self.canWrite || self.draftTitle"))
|
||||
#expect(createFunction.contains("self.errorText = createUnavailableMessage"))
|
||||
#expect(createFunction.contains("return false"))
|
||||
#expect(createFunction.contains("return true"))
|
||||
}
|
||||
|
||||
@Test func taskScopeControlsSendRealGatewayParams() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
|
||||
#expect(source.contains("private var boardScopeMenu: some View"))
|
||||
#expect(source.contains("method: \"workboard.boards.list\""))
|
||||
#expect(source.contains("IPadWorkboardListParams(boardId: selectedBoardParam)"))
|
||||
#expect(source.contains("boardId: selectedBoardParam"))
|
||||
#expect(source
|
||||
.matches(
|
||||
of: /method: "workboard\.cards\.dispatch"[\s\S]*?IPadWorkboardListParams\(boardId: selectedBoardParam\)/)
|
||||
.count == 1)
|
||||
#expect(source.contains("private var agentScopeMenu: some View"))
|
||||
#expect(source.contains("IPadSkillProposalListParams(agentId: selectedAgentParam)"))
|
||||
#expect(source.contains("agentId: selectedAgentParam"))
|
||||
#expect(!source
|
||||
.contains(
|
||||
"params: EmptyParams(),\n timeoutSeconds: 20)\n let response = try JSONDecoder().decode(IPadSkillProposalManifest.self"))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsKeepPhoneNativeActions() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let compactControls = try Self.extract(
|
||||
source,
|
||||
from: "private var compactQueueControls: some View",
|
||||
to: "private var compactRefreshButton: some View")
|
||||
|
||||
#expect(source.contains("struct IPadWorkboardQueueRow"))
|
||||
#expect(source.contains("private var actionMenuItems: some View"))
|
||||
#expect(source.components(separatedBy: ".contextMenu {").count - 1 >= 2)
|
||||
#expect(source.components(separatedBy: ".swipeActions(edge: .leading").count - 1 >= 2)
|
||||
#expect(source.components(separatedBy: ".swipeActions(edge: .trailing").count - 1 >= 2)
|
||||
#expect(source.contains("@State private var presentedProposalRoute: IPadSkillProposalSheetRoute?"))
|
||||
#expect(source.contains(".sheet(item: self.$presentedProposalRoute)"))
|
||||
#expect(source.contains("private func selectProposal("))
|
||||
#expect(!source.contains("proposalSheetPresented"))
|
||||
#expect(source.contains("self.presentedSheet = .card(card)"))
|
||||
#expect(!source.contains("Label(\"Gateway\", systemImage: \"network\")"))
|
||||
#expect(!source.contains("Button(\"Gateway\")"))
|
||||
#expect(!source.contains("actionTitle: self.canRead ? nil : \"Gateway\""))
|
||||
#expect(!source.contains("Workboard offline"))
|
||||
#expect(!source.contains("Workshop offline"))
|
||||
#expect(!source.contains("Connect gateway to"))
|
||||
#expect(source.contains("private var compactRefreshButton: some View"))
|
||||
#expect(source.contains("private var compactBoardScopeMenu: some View"))
|
||||
#expect(source.contains("Color(uiColor: .secondarySystemGroupedBackground)"))
|
||||
#expect(source.contains(".allowsHitTesting(false)"))
|
||||
#expect(compactControls.contains("self.compactRefreshButton"))
|
||||
#expect(compactControls.contains("self.compactBoardScopeMenu"))
|
||||
#expect(!compactControls.contains("Self.workboardSubtitle("))
|
||||
#expect(!compactControls.contains("Label(\"Refresh\""))
|
||||
#expect(compactControls.contains("Label(\"Dispatch\""))
|
||||
}
|
||||
|
||||
@Test func skillWorkshopUsesKanbanLanesOnWideIPad() throws {
|
||||
let source = try String(contentsOf: Self.iPadSkillWorkshopScreenSourceURL(), encoding: .utf8)
|
||||
let previewSource = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
let content = try Self.extract(
|
||||
source,
|
||||
from: "private var proposalContent: some View",
|
||||
to: "private var proposalBoard: some View")
|
||||
let board = try Self.extract(
|
||||
source,
|
||||
from: "private var proposalBoard: some View",
|
||||
to: "private var proposalList: some View")
|
||||
|
||||
#expect(content.contains("if self.isCompactWidth"))
|
||||
#expect(content.contains("self.proposalList"))
|
||||
#expect(content.contains("self.proposalBoard"))
|
||||
#expect(!content.contains("self.proposalDetail"))
|
||||
#expect(board.contains("ScrollView(.horizontal)"))
|
||||
#expect(board.contains("IPadSkillProposalKanbanColumn("))
|
||||
#expect(source.contains("private struct IPadSkillProposalKanbanCard"))
|
||||
#expect(source.contains("static let defaultProposalStatusBoardLanes"))
|
||||
#expect(source.contains("private func proposals(forLaneStatus status: String)"))
|
||||
#expect(previewSource.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
|
||||
#expect(previewSource.contains("private struct IPadSkillWorkshopKanbanPreview"))
|
||||
#expect(previewSource.contains("IPadSkillProposalKanbanColumn("))
|
||||
#expect(previewSource.contains("status: \"needs-review\""))
|
||||
#expect(previewSource.contains("status: \"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func compactTaskRowsHavePopulatedPhonePreviews() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard phone queue rows\")"))
|
||||
#expect(source.contains("#Preview(\"Skill Workshop phone queue rows\")"))
|
||||
#expect(source.contains("private struct IPadWorkboardCompactRowsPreview"))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopCompactRowsPreview"))
|
||||
#expect(source.contains("IPadWorkboardPreviewFixtures.cards"))
|
||||
#expect(source.contains("IPadSkillWorkshopPreviewFixtures.proposals"))
|
||||
}
|
||||
|
||||
@Test func taskScreenPreviewMatricesCoverPrimaryStates() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Workboard states\")"))
|
||||
#expect(source.contains("private struct IPadWorkboardStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Connected\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Empty\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Loading\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Error\")"))
|
||||
#expect(source.contains("title: \"Loading cards\""))
|
||||
#expect(source.contains("title: \"Cards unavailable\""))
|
||||
#expect(source.contains("IPadWorkboardKanbanColumn("))
|
||||
|
||||
#expect(source.contains("#Preview(\"Skill Workshop states\")"))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Offline / Error\")"))
|
||||
#expect(source.contains("title: \"No proposals\""))
|
||||
#expect(source.contains("title: \"Workshop offline\""))
|
||||
#expect(source.contains("title: \"Proposal unavailable\""))
|
||||
#expect(source.contains("#Preview(\n \"Skill Workshop iPad kanban lanes\""))
|
||||
#expect(source.contains("private struct IPadSkillWorkshopKanbanPreview"))
|
||||
#expect(source.contains("\"needs-review\""))
|
||||
#expect(source.contains("\"manual_QA\""))
|
||||
}
|
||||
|
||||
@Test func activityPreviewMatrixCoversConnectionStates() throws {
|
||||
let source = try String(contentsOf: Self.iPadSidebarFeaturePreviewsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("#Preview(\"Activity states\")"))
|
||||
#expect(source.contains("private struct IPadActivityStatesPreview"))
|
||||
#expect(source.contains("self.previewHeader(\"Connected\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Loading\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Empty\")"))
|
||||
#expect(source.contains("self.previewHeader(\"Error\")"))
|
||||
#expect(source.contains("title: \"Sessions unavailable\""))
|
||||
#expect(source.contains("title: \"No recent sessions\""))
|
||||
#expect(source.contains("title: \"Loading sessions\""))
|
||||
}
|
||||
|
||||
@Test func routedFeatureScreensReuseSharedProComponents() throws {
|
||||
let source = try Self.iPadTaskFeatureScreensSource()
|
||||
let componentsSource = try String(contentsOf: Self.proComponentsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(source.contains("ProMetricGrid(metrics: self.metrics)"))
|
||||
#expect(source.contains("ProPanelHeader("))
|
||||
#expect(source.contains("ProStatusRow("))
|
||||
#expect(!source.contains("private struct ProMetricGrid"))
|
||||
#expect(!source.contains("private struct ProMetric"))
|
||||
#expect(!source.contains("private struct ProPanelHeader"))
|
||||
#expect(!source.contains("private struct ProStatusRow"))
|
||||
#expect(!channelsSource.contains("private struct SettingsChannelPanelHeader"))
|
||||
#expect(!channelsSource.contains("private struct SettingsChannelInfoRow"))
|
||||
#expect(componentsSource.contains("struct ProMetricGrid"))
|
||||
#expect(componentsSource.contains("struct ProPanelHeader"))
|
||||
#expect(componentsSource.contains("struct ProStatusRow"))
|
||||
}
|
||||
|
||||
@Test func activityScreenStaysSplitFromTaskFeatureScreens() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let activitySource = try String(contentsOf: Self.iPadActivityScreenSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(activitySource.contains("struct IPadActivityScreen: View"))
|
||||
#expect(activitySource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(activitySource.contains("IPadSidebarScreenChrome("))
|
||||
#expect(!taskSource.contains("struct IPadActivityScreen"))
|
||||
#expect(!taskSource.contains("import OpenClawChatUI"))
|
||||
#expect(projectSource.contains("IPadActivityScreen.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeStaysSplitFromTaskFeatureScreens() throws {
|
||||
let taskSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let projectSource = try String(contentsOf: Self.xcodeProjectSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chromeSource.contains("struct IPadSidebarScreenChrome<Content: View>: View"))
|
||||
#expect(chromeSource.contains("OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)"))
|
||||
#expect(chromeSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(!taskSource.contains("struct IPadSidebarScreenChrome"))
|
||||
#expect(projectSource.contains("IPadSidebarScreenChrome.swift in Sources"))
|
||||
}
|
||||
|
||||
@Test func routedFeatureChromeKeepsGatewayPillActionable() throws {
|
||||
let chromeSource = try String(contentsOf: Self.iPadSidebarScreenChromeSourceURL(), encoding: .utf8)
|
||||
let featureSource = try Self.iPadTaskFeatureScreensSource()
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chromeSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(chromeSource.contains("private var gatewayPill: some View"))
|
||||
#expect(chromeSource.contains("Button(action: gatewayAction)"))
|
||||
#expect(chromeSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(featureSource.matches(of: /gatewayAction: self\.openSettings/).count == 2)
|
||||
#expect(rootSource.contains("IPadActivityScreen("))
|
||||
#expect(rootSource
|
||||
.matches(of: /IPadActivityScreen\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
|
||||
.count == 1)
|
||||
}
|
||||
|
||||
@Test func routedGatewayPillsOpenGatewaySettings() throws {
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let agentSource = try String(contentsOf: Self.agentProTabSourceURL(), encoding: .utf8)
|
||||
let agentOverviewSource = try String(contentsOf: Self.agentProTabOverviewSourceURL(), encoding: .utf8)
|
||||
let overviewSource = try String(contentsOf: Self.commandCenterSourceURL(), encoding: .utf8)
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
|
||||
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
|
||||
#expect(!rootSource.contains("showGatewayActions"))
|
||||
#expect(!rootSource.contains("gatewayActionsDialog"))
|
||||
#expect(overviewSource.contains("Button(action: self.openSettings)"))
|
||||
#expect(overviewSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(agentSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(agentOverviewSource.contains("OpenClawGatewayCompactPill()"))
|
||||
#expect(agentOverviewSource.contains("Button(action: openSettings)"))
|
||||
#expect(rootSource
|
||||
.matches(of: /AgentProTab\([\s\S]*?openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/)
|
||||
.count >= 3)
|
||||
#expect(chatSource.contains("let openSettings: (() -> Void)?"))
|
||||
#expect(chatSource.contains("private var connectionPillButton: some View"))
|
||||
#expect(docsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(settingsSource.contains("NavigationLink(value: SettingsRoute.gateway)"))
|
||||
#expect(rootSource.contains("case .settings:"))
|
||||
#expect(rootSource.contains("SettingsProTab(headerLeadingAction: self.sidebarHeaderLeadingAction)"))
|
||||
#expect(rootSource.contains("directRoute: self.selectedSidebarDestination.settingsRoute ?? .gateway"))
|
||||
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
|
||||
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
|
||||
#expect(settingsSource.contains("route: .channels"))
|
||||
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let trustSource = try String(contentsOf: Self.gatewayTrustPromptAlertSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(sectionsSource.contains("var gatewayDestination: some View"))
|
||||
#expect(sectionsSource.contains("self.gatewayActions"))
|
||||
#expect(sectionsSource.contains("self.manualGatewayCard"))
|
||||
#expect(sectionsSource.contains("self.gatewaySetupCard"))
|
||||
#expect(sectionsSource.contains("self.discoveredGatewaysCard"))
|
||||
#expect(sectionsSource.contains("self.gatewayAdvancedCard"))
|
||||
#expect(sectionsSource.contains("title: \"Reconnect\""))
|
||||
#expect(sectionsSource.contains("Task { await self.reconnectGateway() }"))
|
||||
#expect(sectionsSource.contains("title: \"Diagnose\""))
|
||||
#expect(sectionsSource.contains("Task { await self.runDiagnostics() }"))
|
||||
#expect(sectionsSource.contains("title: \"Scan QR\""))
|
||||
#expect(sectionsSource.contains("self.openGatewayQRScanner()"))
|
||||
#expect(sectionsSource.contains("title: \"Connect\""))
|
||||
#expect(sectionsSource.contains("Task { await self.applySetupCodeAndConnect() }"))
|
||||
#expect(sectionsSource.contains("Task { await self.connect(gateway) }"))
|
||||
#expect(sectionsSource.contains("tailnetWarningText"))
|
||||
#expect(sectionsSource.contains("GatewayProblemBanner("))
|
||||
#expect(sectionsSource.contains("Task { await self.handleGatewayProblemPrimaryAction(problem) }"))
|
||||
|
||||
#expect(actionsSource.contains("await self.gatewayController.connectLastKnown()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
|
||||
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
|
||||
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
|
||||
#expect(actionsSource.contains("Check Tailscale or LAN."))
|
||||
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
#expect(actionsSource.contains("Run /pair approve in your OpenClaw chat"))
|
||||
#expect(actionsSource.contains("self.resetOnboarding()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.trustRotatedGatewayCertificate(from: problem)"))
|
||||
#expect(actionsSource.contains("await self.retryGatewayConnectionFromProblem()"))
|
||||
|
||||
#expect(settingsSource.contains("GatewayProblemDetailsSheet("))
|
||||
#expect(settingsSource.contains("QRScannerView("))
|
||||
#expect(trustSource.contains("Trust this gateway?"))
|
||||
#expect(trustSource.contains("Trust and connect"))
|
||||
#expect(controllerSource.contains("acceptPendingTrustPrompt()"))
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
}
|
||||
|
||||
@Test func gatewaySettingsPreviewMatrixCoversPrimaryStates() throws {
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(supportSource.contains("#Preview(\"Gateway settings states\")"))
|
||||
#expect(supportSource.contains("private struct SettingsGatewayStatesPreview"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Connected\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Loading\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Empty\")"))
|
||||
#expect(supportSource.contains("self.stateSection(\"Error\")"))
|
||||
#expect(supportSource.contains("GatewayProblemBanner("))
|
||||
#expect(supportSource.contains("kind: .pairingRequired"))
|
||||
#expect(supportSource.contains("Run /pair approve in your OpenClaw chat"))
|
||||
#expect(supportSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
#expect(supportSource.contains("self.previewButton(\"Scan QR\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Connect\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Reconnect\""))
|
||||
#expect(supportSource.contains("self.previewButton(\"Diagnose\""))
|
||||
}
|
||||
|
||||
@Test func nativeChatUsesGatewayTransport() throws {
|
||||
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
|
||||
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(chatSource.contains("IOSGatewayChatTransport(gateway: self.appModel.operatorSession)"))
|
||||
#expect(channelsSource.contains("Message routing and external channel clients."))
|
||||
#expect(channelsSource.contains("\"clickclack\": SettingsChannelFallbackMetadata"))
|
||||
#expect(channelsSource.contains("label: \"ClickClack\""))
|
||||
#expect(channelsSource.contains("Self-hosted chat bot routing."))
|
||||
}
|
||||
|
||||
private static func rootTabsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabs.swift")
|
||||
}
|
||||
|
||||
private static func phoneHubSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/RootTabsPhoneControlHub.swift")
|
||||
}
|
||||
|
||||
private static func proComponentsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawProComponents.swift")
|
||||
}
|
||||
|
||||
private static func commandCenterSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/CommandCenterTab.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabOverviewSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab+Overview.swift")
|
||||
}
|
||||
|
||||
private static func agentProTabDestinationsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProTab+Destinations.swift")
|
||||
}
|
||||
|
||||
private static func agentProNodesDestinationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProNodesDestination.swift")
|
||||
}
|
||||
|
||||
private static func agentProDreamingDestinationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/AgentProDreamingDestination.swift")
|
||||
}
|
||||
|
||||
private static func rootTabsNavigationSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/RootTabsNavigation.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarFeatureScreensSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarFeatureScreens.swift")
|
||||
}
|
||||
|
||||
private static func iPadTaskFeatureScreensSource() throws -> String {
|
||||
try [
|
||||
self.iPadWorkboardScreenSourceURL(),
|
||||
self.iPadSkillWorkshopScreenSourceURL(),
|
||||
self.iPadSidebarFeatureScreensSourceURL(),
|
||||
]
|
||||
.map { try String(contentsOf: $0, encoding: .utf8) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func iPadWorkboardScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadWorkboardScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSkillWorkshopScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSkillWorkshopScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarFeaturePreviewsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarFeaturePreviews.swift")
|
||||
}
|
||||
|
||||
private static func iPadActivityScreenSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadActivityScreen.swift")
|
||||
}
|
||||
|
||||
private static func iPadSidebarScreenChromeSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/IPadSidebarScreenChrome.swift")
|
||||
}
|
||||
|
||||
private static func chatProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/ChatProTab.swift")
|
||||
}
|
||||
|
||||
private static func docsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/OpenClawDocsScreen.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSectionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabSections.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabActionsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabActions.swift")
|
||||
}
|
||||
|
||||
private static func settingsProTabSupportSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsProTabSupport.swift")
|
||||
}
|
||||
|
||||
private static func gatewayTrustPromptAlertSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/GatewayTrustPromptAlert.swift")
|
||||
}
|
||||
|
||||
private static func gatewayConnectionControllerSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Gateway/GatewayConnectionController.swift")
|
||||
}
|
||||
|
||||
private static func channelsSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Design/SettingsChannelsDestination.swift")
|
||||
}
|
||||
|
||||
private static func sharedChatPreviewSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("shared/OpenClawKit/Sources/OpenClawChatUI/ChatView+Previews.swift")
|
||||
}
|
||||
|
||||
private static func xcodeProjectSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("OpenClaw.xcodeproj/project.pbxproj")
|
||||
}
|
||||
|
||||
private static func extract(_ source: String, from start: String, to end: String) throws -> String {
|
||||
let startRange = try #require(source.range(of: start))
|
||||
let tail = source[startRange.lowerBound...]
|
||||
let endRange = try #require(tail.range(of: end))
|
||||
return String(tail[..<endRange.lowerBound])
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,8 @@ import UIKit
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct SwiftUIRenderSmokeTests {
|
||||
@MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow {
|
||||
let frame = CGRect(origin: .zero, size: size ?? UIScreen.main.bounds.size)
|
||||
let window = UIWindow(frame: frame)
|
||||
@MainActor private static func host(_ view: some View) -> UIWindow {
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = UIHostingController(rootView: view)
|
||||
window.makeKeyAndVisible()
|
||||
window.rootViewController?.view.setNeedsLayout()
|
||||
@@ -42,102 +41,18 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildsDeviceOrientationShellMatrix() {
|
||||
for scenario in Self.rootTabsShellScenarios() {
|
||||
let appModel = NodeAppModel()
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
.environment(\.rootTabsUserInterfaceIdiomOverride, scenario.idiom)
|
||||
.environment(\.horizontalSizeClass, scenario.horizontalSizeClass)
|
||||
.environment(\.verticalSizeClass, scenario.verticalSizeClass)
|
||||
|
||||
_ = Self.host(root, size: scenario.size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func rootTabsBuildGatewayStateViewHierarchies() {
|
||||
for appModel in Self.rootTabsGatewayStateModels() {
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func phoneControlHubBuildsGatewayStateViewHierarchies() {
|
||||
for appModel in Self.rootTabsGatewayStateModels() {
|
||||
let root = RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
.environment(appModel)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func phoneControlHubBuildsLandscapeCompactState() {
|
||||
@Test @MainActor func rootTabsBuildAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = RootTabsPhoneControlHub(
|
||||
groups: RootTabs.phoneControlGroups,
|
||||
initialDestination: nil,
|
||||
openRootDestination: { _ in })
|
||||
let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let root = RootTabs()
|
||||
.environment(appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
.environment(appModel.voiceWake)
|
||||
.environment(gatewayController)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@Test @MainActor func routedSidebarScreensBuildOfflineStates() {
|
||||
let appModel = NodeAppModel()
|
||||
let screens: [AnyView] = [
|
||||
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(OpenClawDocsScreen()),
|
||||
AnyView(SettingsChannelsScreen()),
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
AnyView(AgentProTab(directRoute: .agents)),
|
||||
AnyView(AgentProTab(directRoute: .instances)),
|
||||
AnyView(CommandSessionsScreen(openChat: {})),
|
||||
AnyView(AgentProTab(directRoute: .dreaming)),
|
||||
AnyView(AgentProTab(directRoute: .usage)),
|
||||
AnyView(AgentProTab(directRoute: .cron)),
|
||||
]
|
||||
|
||||
for screen in screens {
|
||||
let root = NavigationStack { screen }
|
||||
.environment(appModel)
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func taskScreensBuildPhoneLandscapeCompactStates() {
|
||||
let appModel = NodeAppModel()
|
||||
let screens: [AnyView] = [
|
||||
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
|
||||
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
|
||||
]
|
||||
|
||||
for screen in screens {
|
||||
let root = NavigationStack { screen }
|
||||
.environment(appModel)
|
||||
.environment(\.horizontalSizeClass, .regular)
|
||||
.environment(\.verticalSizeClass, .compact)
|
||||
|
||||
_ = Self.host(root)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func voiceWakeWordsViewBuildsAViewHierarchy() {
|
||||
let appModel = NodeAppModel()
|
||||
let root = NavigationStack { VoiceWakeWordsSettingsView() }
|
||||
@@ -149,51 +64,4 @@ import UIKit
|
||||
let root = VoiceWakeToast(command: "openclaw: do something")
|
||||
_ = Self.host(root)
|
||||
}
|
||||
|
||||
@MainActor private static func rootTabsGatewayStateModels() -> [NodeAppModel] {
|
||||
let offlineModel = NodeAppModel()
|
||||
|
||||
let connectingModel = NodeAppModel()
|
||||
connectingModel.gatewayStatusText = "Connecting..."
|
||||
|
||||
let connectedModel = NodeAppModel()
|
||||
connectedModel.enterAppleReviewDemoMode()
|
||||
|
||||
let errorModel = NodeAppModel()
|
||||
errorModel.gatewayStatusText = "Gateway error: connection refused"
|
||||
|
||||
return [offlineModel, connectingModel, connectedModel, errorModel]
|
||||
}
|
||||
|
||||
private static func rootTabsShellScenarios() -> [RootTabsShellScenario] {
|
||||
[
|
||||
RootTabsShellScenario(
|
||||
idiom: .phone,
|
||||
size: CGSize(width: 393, height: 852),
|
||||
horizontalSizeClass: .compact,
|
||||
verticalSizeClass: .regular),
|
||||
RootTabsShellScenario(
|
||||
idiom: .phone,
|
||||
size: CGSize(width: 852, height: 393),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .compact),
|
||||
RootTabsShellScenario(
|
||||
idiom: .pad,
|
||||
size: CGSize(width: 1024, height: 1366),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular),
|
||||
RootTabsShellScenario(
|
||||
idiom: .pad,
|
||||
size: CGSize(width: 1366, height: 1024),
|
||||
horizontalSizeClass: .regular,
|
||||
verticalSizeClass: .regular),
|
||||
]
|
||||
}
|
||||
|
||||
private struct RootTabsShellScenario {
|
||||
let idiom: UIUserInterfaceIdiom
|
||||
let size: CGSize
|
||||
let horizontalSizeClass: UserInterfaceSizeClass
|
||||
let verticalSizeClass: UserInterfaceSizeClass
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ targets:
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_APP_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
@@ -183,7 +183,7 @@ targets:
|
||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_SHARE_BUNDLE_ID)"
|
||||
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_SHARE_PROFILE)"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
info:
|
||||
@@ -220,7 +220,7 @@ targets:
|
||||
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
|
||||
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
|
||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
TARGETED_DEVICE_FAMILY: "1"
|
||||
SWIFT_VERSION: "6.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||
|
||||
@@ -72,7 +72,7 @@ final class CronJobsStore {
|
||||
do {
|
||||
if let status = try? await GatewayConnection.shared.cronStatus() {
|
||||
self.schedulerEnabled = status.enabled
|
||||
self.schedulerStorePath = status.sqlitePath ?? status.storePath
|
||||
self.schedulerStorePath = status.storePath
|
||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||
}
|
||||
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
@@ -230,12 +229,6 @@ enum ExecApprovalsStore {
|
||||
private static let secureStateDirPermissions = 0o700
|
||||
private static let fileLock = NSRecursiveLock()
|
||||
|
||||
private enum LegacyMigrationResult {
|
||||
case notNeeded
|
||||
case migrated
|
||||
case blocked
|
||||
}
|
||||
|
||||
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
self.fileLock.lock()
|
||||
defer { self.fileLock.unlock() }
|
||||
@@ -250,195 +243,6 @@ enum ExecApprovalsStore {
|
||||
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||
}
|
||||
|
||||
private static func legacyStateDirURLs() -> [URL] {
|
||||
if let home = OpenClawEnv.path("OPENCLAW_HOME") {
|
||||
var urls = [
|
||||
URL(fileURLWithPath: home, isDirectory: true)
|
||||
.appendingPathComponent(".openclaw", isDirectory: true),
|
||||
]
|
||||
let osHomeURL = FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
if !urls.contains(where: {
|
||||
$0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path
|
||||
}) {
|
||||
urls.append(osHomeURL)
|
||||
}
|
||||
return urls
|
||||
}
|
||||
return [
|
||||
FileManager().homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".openclaw", isDirectory: true),
|
||||
]
|
||||
}
|
||||
|
||||
private static func legacyFileURLIfPending() -> URL? {
|
||||
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil }
|
||||
let targetURL = self.fileURL()
|
||||
for stateDirURL in self.legacyStateDirURLs() {
|
||||
let legacyURL = stateDirURL
|
||||
.appendingPathComponent("exec-approvals.json", isDirectory: false)
|
||||
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
|
||||
continue
|
||||
}
|
||||
guard FileManager().fileExists(atPath: legacyURL.path) else { continue }
|
||||
guard !FileManager().fileExists(atPath: targetURL.path) else { return nil }
|
||||
return legacyURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
|
||||
ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: nil,
|
||||
defaults: ExecApprovalsDefaults(
|
||||
security: .deny,
|
||||
ask: .always,
|
||||
askFallback: .deny,
|
||||
autoAllowSkills: nil),
|
||||
agents: [:])
|
||||
}
|
||||
|
||||
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return true }
|
||||
let expanded = self.expandPath(trimmed)
|
||||
let legacySocket = legacyFileURL.deletingLastPathComponent()
|
||||
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
|
||||
.path
|
||||
return URL(fileURLWithPath: expanded).standardizedFileURL.path
|
||||
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
|
||||
}
|
||||
|
||||
private static func hasSymlinkParent(_ url: URL) -> Bool {
|
||||
var cursor = url.deletingLastPathComponent()
|
||||
let manager = FileManager()
|
||||
while true {
|
||||
var isDirectory = ObjCBool(false)
|
||||
if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) {
|
||||
if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
let parent = cursor.deletingLastPathComponent()
|
||||
if parent.path == cursor.path { return false }
|
||||
cursor = parent
|
||||
}
|
||||
}
|
||||
|
||||
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
|
||||
let manager = FileManager()
|
||||
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
|
||||
if manager.fileExists(atPath: archiveURL.path) {
|
||||
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
|
||||
}
|
||||
try manager.moveItem(at: legacyURL, to: archiveURL)
|
||||
return archiveURL
|
||||
}
|
||||
|
||||
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
|
||||
let tempURL = targetURL.deletingLastPathComponent()
|
||||
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
|
||||
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
|
||||
if fd == -1 {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
var closed = false
|
||||
defer {
|
||||
if !closed { close(fd) }
|
||||
}
|
||||
do {
|
||||
try data.withUnsafeBytes { rawBuffer in
|
||||
guard let base = rawBuffer.baseAddress else { return }
|
||||
var offset = 0
|
||||
while offset < rawBuffer.count {
|
||||
let written = Darwin.write(
|
||||
fd,
|
||||
base.advanced(by: offset),
|
||||
rawBuffer.count - offset)
|
||||
if written < 0 {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
offset += written
|
||||
}
|
||||
}
|
||||
close(fd)
|
||||
closed = true
|
||||
let copied = copyfile(
|
||||
tempURL.path,
|
||||
targetURL.path,
|
||||
nil,
|
||||
copyfile_flags_t(COPYFILE_EXCL))
|
||||
if copied == -1 {
|
||||
if errno == EEXIST {
|
||||
try? FileManager().removeItem(at: tempURL)
|
||||
return false
|
||||
}
|
||||
try? FileManager().removeItem(at: targetURL)
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
|
||||
}
|
||||
try? FileManager().removeItem(at: tempURL)
|
||||
return true
|
||||
} catch {
|
||||
try? FileManager().removeItem(at: tempURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
|
||||
guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded }
|
||||
let targetURL = self.fileURL()
|
||||
do {
|
||||
if self.hasSymlinkParent(targetURL) {
|
||||
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "target path has a symlink parent",
|
||||
])
|
||||
}
|
||||
let data = try Data(contentsOf: legacyURL)
|
||||
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
guard file.version == 1 else {
|
||||
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
|
||||
])
|
||||
}
|
||||
file = self.normalizeIncoming(file)
|
||||
let rawSocketPath = file.socket?.path?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
|
||||
if file.socket == nil {
|
||||
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
|
||||
}
|
||||
file.socket?.path = self.socketPath()
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let migrated = try encoder.encode(file)
|
||||
self.ensureSecureStateDirectory()
|
||||
try FileManager().createDirectory(
|
||||
at: targetURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded }
|
||||
let created = try self.writeMigratedFileExclusively(migrated, to: targetURL)
|
||||
if !created { return .notNeeded }
|
||||
try? FileManager().setAttributes(
|
||||
[.posixPermissions: 0o600],
|
||||
ofItemAtPath: targetURL.path)
|
||||
do {
|
||||
_ = try self.archiveMigratedLegacyFile(legacyURL)
|
||||
} catch {
|
||||
self.logger
|
||||
.warning(
|
||||
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
return .migrated
|
||||
} catch {
|
||||
self.logger
|
||||
.error(
|
||||
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
|
||||
return .blocked
|
||||
}
|
||||
}
|
||||
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -474,14 +278,6 @@ enum ExecApprovalsStore {
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
self.withFileLock {
|
||||
if self.legacyFileURLIfPending() != nil {
|
||||
let file = self.unmigratedLegacyFallbackFile()
|
||||
return ExecApprovalsSnapshot(
|
||||
path: self.fileURL().path,
|
||||
exists: false,
|
||||
hash: self.hashRaw(nil),
|
||||
file: file)
|
||||
}
|
||||
let url = self.fileURL()
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsSnapshot(
|
||||
@@ -526,14 +322,6 @@ enum ExecApprovalsStore {
|
||||
|
||||
static func loadFile() -> ExecApprovalsFile {
|
||||
self.withFileLock {
|
||||
if self.legacyFileURLIfPending() != nil {
|
||||
switch self.migrateLegacyFileIfNeeded() {
|
||||
case .migrated, .notNeeded:
|
||||
break
|
||||
case .blocked:
|
||||
return self.unmigratedLegacyFallbackFile()
|
||||
}
|
||||
}
|
||||
let url = self.fileURL()
|
||||
guard FileManager().fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
@@ -573,14 +361,6 @@ enum ExecApprovalsStore {
|
||||
|
||||
static func ensureFile() -> ExecApprovalsFile {
|
||||
self.withFileLock {
|
||||
if self.legacyFileURLIfPending() != nil {
|
||||
switch self.migrateLegacyFileIfNeeded() {
|
||||
case .migrated, .notNeeded:
|
||||
break
|
||||
case .blocked:
|
||||
return self.unmigratedLegacyFallbackFile()
|
||||
}
|
||||
}
|
||||
self.ensureSecureStateDirectory()
|
||||
let url = self.fileURL()
|
||||
let existed = FileManager().fileExists(atPath: url.path)
|
||||
|
||||
@@ -775,7 +775,6 @@ extension GatewayConnection {
|
||||
struct CronSchedulerStatus: Decodable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let sqlitePath: String?
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@ enum HostEnvSanitizer {
|
||||
private static let gitAllowProtocolKey = "GIT_ALLOW_PROTOCOL"
|
||||
private static let gitProtocolFromUserKey = "GIT_PROTOCOL_FROM_USER"
|
||||
private static let gitProtocolFromUserDisabledValue = "0"
|
||||
private static let cargoTargetExecutableOverridePattern =
|
||||
#"^CARGO_TARGET_[A-Z0-9_]+_(LINKER|RUNNER)$"#
|
||||
private static let gitDefaultAlwaysAllowedProtocols: Set<String> = [
|
||||
"git",
|
||||
"http",
|
||||
@@ -48,12 +46,6 @@ enum HostEnvSanitizer {
|
||||
|
||||
private static func isBlockedOverride(_ upperKey: String) -> Bool {
|
||||
if self.blockedOverrideKeys.contains(upperKey) { return true }
|
||||
if upperKey.range(
|
||||
of: self.cargoTargetExecutableOverridePattern,
|
||||
options: .regularExpression) != nil
|
||||
{
|
||||
return true
|
||||
}
|
||||
return self.blockedOverridePrefixes.contains(where: { upperKey.hasPrefix($0) })
|
||||
}
|
||||
|
||||
|
||||
@@ -37,9 +37,7 @@ enum HostEnvSecurityPolicy {
|
||||
"BZR_PLUGIN_PATH",
|
||||
"BZR_SSH",
|
||||
"CARGO_BUILD_RUSTC",
|
||||
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"CARGO_BUILD_RUSTDOC",
|
||||
"CARGO_HOME",
|
||||
"CATALINA_OPTS",
|
||||
"CC",
|
||||
@@ -115,8 +113,6 @@ enum HostEnvSecurityPolicy {
|
||||
"GVIMINIT",
|
||||
"HELM_HOME",
|
||||
"HELM_PLUGINS",
|
||||
"HGEDITOR",
|
||||
"HGMERGE",
|
||||
"HGRCPATH",
|
||||
"HOSTALIASES",
|
||||
"IFS",
|
||||
@@ -136,7 +132,6 @@ enum HostEnvSecurityPolicy {
|
||||
"LUA_INIT_5_3",
|
||||
"LUA_INIT_5_4",
|
||||
"LUA_PATH",
|
||||
"MAKE",
|
||||
"MAKEFLAGS",
|
||||
"MAVEN_OPTS",
|
||||
"MFLAGS",
|
||||
@@ -177,10 +172,7 @@ enum HostEnvSecurityPolicy {
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"RUBYSHELL",
|
||||
"RUSTC",
|
||||
"RUSTC_WORKSPACE_WRAPPER",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTDOC",
|
||||
"RUSTFLAGS",
|
||||
"R_ENVIRON",
|
||||
"R_ENVIRON_USER",
|
||||
@@ -228,9 +220,7 @@ enum HostEnvSecurityPolicy {
|
||||
"BZR_PLUGIN_PATH",
|
||||
"BZR_SSH",
|
||||
"CARGO_BUILD_RUSTC",
|
||||
"CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER",
|
||||
"CARGO_BUILD_RUSTC_WRAPPER",
|
||||
"CARGO_BUILD_RUSTDOC",
|
||||
"CATALINA_OPTS",
|
||||
"CC",
|
||||
"CMAKE_CXX_COMPILER",
|
||||
@@ -273,8 +263,6 @@ enum HostEnvSecurityPolicy {
|
||||
"GRADLE_OPTS",
|
||||
"GVIMINIT",
|
||||
"HELM_PLUGINS",
|
||||
"HGEDITOR",
|
||||
"HGMERGE",
|
||||
"HGRCPATH",
|
||||
"HOSTALIASES",
|
||||
"IFS",
|
||||
@@ -288,7 +276,6 @@ enum HostEnvSecurityPolicy {
|
||||
"LUA_INIT_5_2",
|
||||
"LUA_INIT_5_3",
|
||||
"LUA_INIT_5_4",
|
||||
"MAKE",
|
||||
"MAKEFLAGS",
|
||||
"MAVEN_OPTS",
|
||||
"MFLAGS",
|
||||
@@ -309,10 +296,7 @@ enum HostEnvSecurityPolicy {
|
||||
"RUBYLIB",
|
||||
"RUBYOPT",
|
||||
"RUBYSHELL",
|
||||
"RUSTC",
|
||||
"RUSTC_WORKSPACE_WRAPPER",
|
||||
"RUSTC_WRAPPER",
|
||||
"RUSTDOC",
|
||||
"R_ENVIRON",
|
||||
"R_ENVIRON_USER",
|
||||
"R_PROFILE",
|
||||
|
||||
@@ -47,20 +47,19 @@ struct VoiceWakeSettings: View {
|
||||
private var voiceSummaryPanel: some View {
|
||||
let enabled = voiceWakeSupported && self.state.swabbleEnabled
|
||||
let pushToTalk = voiceWakeSupported && self.state.voicePushToTalkEnabled
|
||||
let statusColor: Color = !voiceWakeSupported ? .orange : enabled || pushToTalk ? .green : .secondary
|
||||
|
||||
return HStack(alignment: .center, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(statusColor.opacity(0.18))
|
||||
Image(systemName: self.voiceSummaryIconName(enabled: enabled))
|
||||
.fill((enabled || pushToTalk ? Color.green : Color.secondary).opacity(0.18))
|
||||
Image(systemName: enabled ? "waveform.badge.mic" : "mic.slash")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(statusColor)
|
||||
.foregroundStyle(enabled || pushToTalk ? .green : .secondary)
|
||||
}
|
||||
.frame(width: 46, height: 46)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(self.voiceSummaryTitle(enabled: enabled, pushToTalk: pushToTalk))
|
||||
Text(enabled ? "Voice Wake active" : pushToTalk ? "Push-to-talk active" : "Voice controls idle")
|
||||
.font(.headline)
|
||||
Text(self.voiceSummarySubtitle)
|
||||
.font(.footnote)
|
||||
@@ -85,26 +84,6 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func voiceSummaryIconName(enabled: Bool) -> String {
|
||||
if !voiceWakeSupported {
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
return enabled ? "waveform.badge.mic" : "mic.slash"
|
||||
}
|
||||
|
||||
private func voiceSummaryTitle(enabled: Bool, pushToTalk: Bool) -> String {
|
||||
if !voiceWakeSupported {
|
||||
return "Voice Wake unavailable"
|
||||
}
|
||||
if enabled {
|
||||
return "Voice Wake active"
|
||||
}
|
||||
if pushToTalk {
|
||||
return "Push-to-talk active"
|
||||
}
|
||||
return "Voice controls idle"
|
||||
}
|
||||
|
||||
private var voiceSummarySubtitle: String {
|
||||
if !voiceWakeSupported {
|
||||
return "Voice Wake requires macOS 26 or newer."
|
||||
@@ -119,31 +98,16 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private var unsupportedVoiceWakePanel: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 28)
|
||||
.padding(.top, 1)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Voice Wake requires macOS 26 or newer")
|
||||
.font(.callout.weight(.semibold))
|
||||
Text("The Voice Wake and push-to-talk controls are hidden on older macOS versions.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
.foregroundStyle(.yellow)
|
||||
Text("Voice Wake requires macOS 26 or newer.")
|
||||
.font(.callout.weight(.medium))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(.orange.opacity(0.18))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(.yellow.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -155,68 +119,72 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
self.voiceSummaryPanel
|
||||
|
||||
if voiceWakeSupported {
|
||||
SettingsCardGroup("Activation") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Voice Wake",
|
||||
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
|
||||
binding: self.voiceWakeBinding)
|
||||
SettingsCardGroup("Activation") {
|
||||
SettingsCardToggleRow(
|
||||
title: "Enable Voice Wake",
|
||||
subtitle: "Listen for a wake phrase before running voice commands. Recognition runs fully on-device.",
|
||||
binding: self.voiceWakeBinding)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Trigger Talk Mode",
|
||||
subtitle: "Start a full voice conversation when a wake phrase is detected.",
|
||||
binding: self.$state.voiceWakeTriggersTalkMode)
|
||||
.disabled(!self.state.swabbleEnabled)
|
||||
SettingsCardToggleRow(
|
||||
title: "Trigger Talk Mode",
|
||||
subtitle: "Start a full voice conversation when a wake phrase is detected.",
|
||||
binding: self.$state.voiceWakeTriggersTalkMode)
|
||||
.disabled(!self.state.swabbleEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Hold Right Option to talk",
|
||||
subtitle: "Start listening while you hold the key and show the preview overlay.",
|
||||
binding: self.$state.voicePushToTalkEnabled)
|
||||
SettingsCardToggleRow(
|
||||
title: "Hold Right Option to talk",
|
||||
subtitle: "Start listening while you hold the key and show the preview overlay.",
|
||||
binding: self.$state.voicePushToTalkEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
|
||||
SettingsCardRow(
|
||||
title: "Push-to-talk paused",
|
||||
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
|
||||
{
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
|
||||
SettingsCardRow(
|
||||
title: "Push-to-talk paused",
|
||||
subtitle: "Push-to-Talk resumes when Talk Mode is turned off.")
|
||||
{
|
||||
Image(systemName: "pause.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Play phase-transition sounds",
|
||||
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
|
||||
binding: self.$state.talkPhaseSoundsEnabled)
|
||||
|
||||
SettingsCardToggleRow(
|
||||
title: "Right Option stops speech",
|
||||
subtitle: "Tap Right Option to interrupt speech and return to listening.",
|
||||
binding: self.$state.talkShiftToStopEnabled,
|
||||
showsDivider: false)
|
||||
}
|
||||
|
||||
SettingsCardGroup("Recognition") {
|
||||
self.localePicker
|
||||
self.micPicker
|
||||
self.levelMeter
|
||||
}
|
||||
SettingsCardToggleRow(
|
||||
title: "Play phase-transition sounds",
|
||||
subtitle: "Play short sounds when Talk Mode switches between listening, thinking, and speaking.",
|
||||
binding: self.$state.talkPhaseSoundsEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
SettingsCardGroup("Test") {
|
||||
VoiceWakeTestCard(
|
||||
testState: self.$testState,
|
||||
isTesting: self.$isTesting,
|
||||
onToggle: self.toggleTest)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
SettingsCardToggleRow(
|
||||
title: "Right Option stops speech",
|
||||
subtitle: "Tap Right Option to interrupt speech and return to listening.",
|
||||
binding: self.$state.talkShiftToStopEnabled,
|
||||
showsDivider: false)
|
||||
.disabled(!voiceWakeSupported)
|
||||
}
|
||||
|
||||
self.chimeSection
|
||||
|
||||
self.triggerTable
|
||||
} else {
|
||||
if !voiceWakeSupported {
|
||||
self.unsupportedVoiceWakePanel
|
||||
}
|
||||
|
||||
SettingsCardGroup("Recognition") {
|
||||
self.localePicker
|
||||
self.micPicker
|
||||
self.levelMeter
|
||||
}
|
||||
|
||||
SettingsCardGroup("Test") {
|
||||
VoiceWakeTestCard(
|
||||
testState: self.$testState,
|
||||
isTesting: self.$isTesting,
|
||||
onToggle: self.toggleTest)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
self.chimeSection
|
||||
|
||||
self.triggerTable
|
||||
|
||||
Spacer(minLength: 8)
|
||||
}
|
||||
.settingsDetailContent()
|
||||
@@ -249,13 +217,9 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private func activateLivePreview() {
|
||||
self.loadTriggerEntries()
|
||||
guard voiceWakeSupported else {
|
||||
self.deactivateLivePreview()
|
||||
return
|
||||
}
|
||||
self.meterStartupTask?.cancel()
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
await self.loadMicsIfNeeded()
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
@@ -280,11 +244,6 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
|
||||
private func scheduleMeterRestart() {
|
||||
guard voiceWakeSupported else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
Task { await self.meter.stop() }
|
||||
return
|
||||
}
|
||||
self.meterStartupTask?.cancel()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
@@ -703,7 +662,7 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
@MainActor
|
||||
private func scheduleMicRefresh() {
|
||||
guard voiceWakeSupported, self.isActive else { return }
|
||||
guard self.isActive else { return }
|
||||
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||
await self.loadMicsIfNeeded(force: true)
|
||||
await self.restartMeter()
|
||||
@@ -765,11 +724,6 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
@MainActor
|
||||
private func restartMeter() async {
|
||||
guard voiceWakeSupported else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
guard self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
@@ -936,7 +890,6 @@ extension VoiceWakeSettings {
|
||||
_ = view.levelMeter
|
||||
_ = view.triggerTable
|
||||
_ = view.chimeSection
|
||||
_ = view.unsupportedVoiceWakePanel
|
||||
|
||||
view.addWord()
|
||||
if let entryId = view.triggerEntries.first?.id {
|
||||
|
||||
@@ -16,23 +16,6 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
private func withTempHomeAndStateDir(
|
||||
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
|
||||
{
|
||||
let root = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let home = root.appendingPathComponent("home", isDirectory: true)
|
||||
let stateDir = root.appendingPathComponent("state", isDirectory: true)
|
||||
defer { try? FileManager().removeItem(at: root) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_HOME": home.path,
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
]) {
|
||||
try await body(home, stateDir)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file skips rewrite when unchanged`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
@@ -47,50 +30,6 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `ensure file migrates default approvals into custom state dir`() async throws {
|
||||
try await self.withTempHomeAndStateDir { home, stateDir in
|
||||
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
try FileManager().createDirectory(
|
||||
at: legacyDir,
|
||||
withIntermediateDirectories: true)
|
||||
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
|
||||
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
|
||||
let legacyJson = """
|
||||
{
|
||||
"version": 1,
|
||||
"socket": {
|
||||
"path": "\(legacySocket)",
|
||||
"token": "legacy-token"
|
||||
},
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "always"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"allowlist": [{ "pattern": "git status" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try Data(legacyJson.utf8).write(to: legacyFile)
|
||||
|
||||
let file = ExecApprovalsStore.ensureFile()
|
||||
let targetURL = ExecApprovalsStore.fileURL()
|
||||
|
||||
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
|
||||
#expect(FileManager().fileExists(atPath: targetURL.path))
|
||||
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
|
||||
#expect(file.socket?.token == "legacy-token")
|
||||
#expect(file.defaults?.security == .deny)
|
||||
#expect(file.defaults?.ask == .always)
|
||||
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
|
||||
#expect(!FileManager().fileExists(atPath: legacyFile.path))
|
||||
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `update allowlist accepts basename pattern`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
|
||||
enum Scenario {
|
||||
case connected
|
||||
case empty
|
||||
case loading
|
||||
case error
|
||||
}
|
||||
|
||||
let scenario: Scenario
|
||||
|
||||
init(scenario: Scenario = .connected) {
|
||||
self.scenario = scenario
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
switch self.scenario {
|
||||
case .connected:
|
||||
break
|
||||
case .empty:
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-empty-session",
|
||||
messages: [],
|
||||
thinkingLevel: "medium")
|
||||
case .loading:
|
||||
try await Task.sleep(nanoseconds: 60_000_000_000)
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-loading-session",
|
||||
messages: [],
|
||||
thinkingLevel: "medium")
|
||||
case .error:
|
||||
throw NSError(
|
||||
domain: "OpenClawChatPreviewTransport",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Gateway not connected. Check Tailscale and retry."])
|
||||
}
|
||||
|
||||
return OpenClawChatHistoryPayload(
|
||||
sessionKey: sessionKey,
|
||||
sessionId: "preview-session",
|
||||
messages: [
|
||||
Self.message(
|
||||
role: "user",
|
||||
text: "Can you check the gateway status and summarize anything risky?",
|
||||
timestamp: 1),
|
||||
Self.message(
|
||||
role: "assistant",
|
||||
text: "Gateway is reachable. The only notable item is that push relay is still using local distribution, so device tests should stay on the local lane.",
|
||||
timestamp: 2),
|
||||
Self.toolCall(
|
||||
id: "tool-preview-1",
|
||||
name: "gateway.status",
|
||||
arguments: ["deep": AnyCodable(true)],
|
||||
timestamp: 3),
|
||||
Self.toolResult(
|
||||
toolCallId: "tool-preview-1",
|
||||
name: "gateway.status",
|
||||
text: "status=ok, channels=ios,macos, lastHeartbeat=12s",
|
||||
timestamp: 4),
|
||||
],
|
||||
thinkingLevel: "medium")
|
||||
}
|
||||
|
||||
func listModels() async throws -> [OpenClawChatModelChoice] {
|
||||
[
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
contextWindow: 400_000),
|
||||
OpenClawChatModelChoice(
|
||||
modelID: "sonnet-4.6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200_000),
|
||||
]
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
|
||||
}
|
||||
|
||||
func listSessions(limit _: Int?) async throws -> OpenClawChatSessionsListResponse {
|
||||
OpenClawChatSessionsListResponse(
|
||||
ts: 0,
|
||||
path: nil,
|
||||
count: 2,
|
||||
defaults: OpenClawChatSessionsDefaults(
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 400_000,
|
||||
thinkingLevels: [
|
||||
OpenClawChatThinkingLevelOption(id: "off", label: "off"),
|
||||
OpenClawChatThinkingLevelOption(id: "medium", label: "medium"),
|
||||
OpenClawChatThinkingLevelOption(id: "high", label: "high"),
|
||||
],
|
||||
thinkingDefault: "medium",
|
||||
mainSessionKey: "main"),
|
||||
sessions: [
|
||||
Self.session(key: "main", displayName: "Main", updatedAt: 2),
|
||||
Self.session(key: "ios-preview", displayName: "iOS preview", updatedAt: 1),
|
||||
])
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
switch self.scenario {
|
||||
case .connected, .empty, .loading:
|
||||
true
|
||||
case .error:
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_: String) async throws {}
|
||||
|
||||
private static func message(role: String, text: String, timestamp: Double) -> AnyCodable {
|
||||
AnyCodable([
|
||||
"role": role,
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolCall(
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: [String: AnyCodable],
|
||||
timestamp: Double) -> AnyCodable
|
||||
{
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
[
|
||||
"type": "toolCall",
|
||||
"id": id,
|
||||
"name": name,
|
||||
"arguments": AnyCodable(arguments),
|
||||
],
|
||||
],
|
||||
"timestamp": timestamp,
|
||||
])
|
||||
}
|
||||
|
||||
private static func toolResult(
|
||||
toolCallId: String,
|
||||
name: String,
|
||||
text: String,
|
||||
timestamp: Double) -> AnyCodable
|
||||
{
|
||||
AnyCodable([
|
||||
"role": "tool",
|
||||
"content": [["type": "text", "text": text]],
|
||||
"timestamp": timestamp,
|
||||
"toolCallId": toolCallId,
|
||||
"toolName": name,
|
||||
])
|
||||
}
|
||||
|
||||
private static func session(
|
||||
key: String,
|
||||
displayName: String,
|
||||
updatedAt: Double) -> OpenClawChatSessionEntry
|
||||
{
|
||||
OpenClawChatSessionEntry(
|
||||
key: key,
|
||||
kind: nil,
|
||||
displayName: displayName,
|
||||
surface: "ios",
|
||||
subject: nil,
|
||||
room: nil,
|
||||
space: nil,
|
||||
updatedAt: updatedAt,
|
||||
sessionId: nil,
|
||||
systemSent: nil,
|
||||
abortedLastRun: nil,
|
||||
thinkingLevel: "medium",
|
||||
verboseLevel: nil,
|
||||
inputTokens: 2500,
|
||||
outputTokens: 900,
|
||||
totalTokens: 3400,
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
contextTokens: 400_000)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Chat") {
|
||||
OpenClawChatPreview(scenario: .connected)
|
||||
}
|
||||
|
||||
#Preview("Chat connected") {
|
||||
OpenClawChatPreview(scenario: .connected)
|
||||
}
|
||||
|
||||
#Preview("Chat empty") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .empty,
|
||||
sessionKey: "empty-preview")
|
||||
}
|
||||
|
||||
#Preview("Chat loading") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .loading,
|
||||
sessionKey: "loading-preview")
|
||||
}
|
||||
|
||||
#Preview("Chat gateway error") {
|
||||
OpenClawChatPreview(
|
||||
scenario: .error,
|
||||
sessionKey: "error-preview")
|
||||
}
|
||||
|
||||
#Preview("Onboarding chat") {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
sessionKey: "ios-preview",
|
||||
transport: OpenClawChatPreviewTransport()),
|
||||
showsSessionSwitcher: false,
|
||||
style: .onboarding,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue)
|
||||
}
|
||||
|
||||
private struct OpenClawChatPreview: View {
|
||||
let scenario: OpenClawChatPreviewTransport.Scenario
|
||||
var sessionKey: String = "main"
|
||||
|
||||
var body: some View {
|
||||
OpenClawChatView(
|
||||
viewModel: OpenClawChatViewModel(
|
||||
sessionKey: self.sessionKey,
|
||||
transport: OpenClawChatPreviewTransport(scenario: self.scenario)),
|
||||
showsSessionSwitcher: true,
|
||||
style: .standard,
|
||||
markdownVariant: .standard,
|
||||
userAccent: .blue,
|
||||
showsAssistantTrace: true)
|
||||
}
|
||||
}
|
||||
@@ -982,25 +982,21 @@ public struct WakeParams: Codable, Sendable {
|
||||
public let mode: AnyCodable
|
||||
public let text: String
|
||||
public let sessionkey: String?
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
mode: AnyCodable,
|
||||
text: String,
|
||||
sessionkey: String?,
|
||||
agentid: String? = nil)
|
||||
sessionkey: String?)
|
||||
{
|
||||
self.mode = mode
|
||||
self.text = text
|
||||
self.sessionkey = sessionkey
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case mode
|
||||
case text
|
||||
case sessionkey = "sessionKey"
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
37b56008790612b8293930b6a29d74490e98daa90f954fca9d133fcc28645c4c config-baseline.json
|
||||
75b64c2ea081369ba4306493313a8a4cd48b784145f92fed995e6b77a5df350d config-baseline.core.json
|
||||
17d64c9799dfa239a49493413f1100bdd9237e9b67aaeae331a4604dbc227023 config-baseline.channel.json
|
||||
f9d1f50bfa8403891e76cd99dc1357cdece4a71e8ae18a39b190c2a14e6f97b0 config-baseline.plugin.json
|
||||
36e44e38957b56b7b0782e5f14add447870d578248d3bd0ebf9a7e884c406a0f config-baseline.json
|
||||
7b2f99fdd52527397bf3520d923e7b4eec6b0091a7a7a006f6f39bedb931404c config-baseline.core.json
|
||||
1d1cd6d624d7f4b8137b8a945c7734530aa31170983c3b216de27e6e10126034 config-baseline.channel.json
|
||||
b0dec5acfe60557e728e5ad03cc36d19d2432d51f755656c97846afa7fbe374a config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
2c783beea6b3cda3d79060739a923f9f39e7e8b5942123dd6b08a09143a587ca plugin-sdk-api-baseline.json
|
||||
0b33af2cffb42abb46682fb71c8f214da220793f13d10a34d332e75ff99e8ce9 plugin-sdk-api-baseline.jsonl
|
||||
ae06e87a060aaa9618e2b245553d90402c0fbbe1ebc864928dc7f771cede7c6d plugin-sdk-api-baseline.json
|
||||
8ae4665726d0a8e2e80587ab0b98afce6718861a996daef2fac207066c29dd4f plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -59,14 +59,6 @@ export CLICKCLACK_BOT_TOKEN="ccb_..."
|
||||
openclaw gateway
|
||||
```
|
||||
|
||||
If `plugins.allow` is a non-empty restrictive list, explicitly selecting
|
||||
ClickClack in channel setup or running `openclaw plugins enable clickclack`
|
||||
appends `clickclack` to that list. Onboarding installation uses the same
|
||||
explicit-selection behavior. These paths do not override `plugins.deny` or a
|
||||
global `plugins.enabled: false` setting. Direct `openclaw plugins install
|
||||
clickclack` follows the normal plugin-install policy and also records ClickClack
|
||||
in an existing allowlist.
|
||||
|
||||
## Multiple bots
|
||||
|
||||
Each account opens its own ClickClack realtime connection and uses its own bot token.
|
||||
|
||||
@@ -763,31 +763,6 @@ imessage: suppressed stale inbound backlog account=<id> sent=<iso> recovery=<boo
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Messages send but inbound iMessages do not arrive">
|
||||
First prove whether the message reached the local Mac. If `chat.db` does not change, OpenClaw cannot receive the message even when `imsg status --json` reports a healthy bridge.
|
||||
|
||||
```bash
|
||||
imsg chats --limit 10 --json
|
||||
imsg watch --chat-id <chat-id> --json
|
||||
sqlite3 ~/Library/Messages/chat.db \
|
||||
"select datetime(max(date)/1000000000 + 978307200, 'unixepoch', 'localtime'), max(ROWID) from message;"
|
||||
```
|
||||
|
||||
If phone-sent messages create no new rows, repair the macOS Messages and Apple Push layer before changing OpenClaw config. A one-shot service refresh is often enough:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k system/com.apple.apsd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.CommCenter
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.identityservicesd
|
||||
launchctl kickstart -k gui/$(id -u)/com.apple.imagent
|
||||
imsg launch
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Send a fresh iMessage from the phone and confirm a new `chat.db` row or `imsg watch` event before debugging OpenClaw sessions. Do not run this as a periodic bridge-relaunch loop; repeated `imsg launch` plus gateway restarts during active work can interrupt deliveries and strand in-flight channel runs.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gateway is not running on macOS">
|
||||
The default `cliPath: "imsg"` must run on the Mac signed into Messages. On Linux or Windows, set `channels.imessage.cliPath` to a wrapper script that SSHes to that Mac and runs `imsg "$@"`.
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ The workflow installs OCM from a pinned release and Kova from `openclaw/Kova` at
|
||||
- `mock-deep-profile`: CPU/heap/trace profiling for startup, gateway, and agent-turn hotspots.
|
||||
- `live-openai-candidate`: a real OpenAI `openai/gpt-5.5` agent turn, skipped when `OPENAI_API_KEY` is unavailable.
|
||||
|
||||
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, CLI startup commands against the booted gateway, and the SQLite state smoke performance probe. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
|
||||
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, and CLI startup commands against the booted gateway. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
|
||||
|
||||
Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured, the workflow also commits `report.json`, `report.md`, bundles, `index.md`, and source-probe artifacts into `openclaw/clawgrit-reports` under `openclaw-performance/<tested-ref>/<run-id>-<attempt>/<lane>/`. The current tested-ref pointer is written as `openclaw-performance/<tested-ref>/latest-<lane>.json`.
|
||||
|
||||
@@ -452,7 +452,7 @@ For normal PRs, follow scoped CI/check evidence instead of treating parity as a
|
||||
|
||||
The `CodeQL` workflow is intentionally a narrow first-pass security scanner, not the full repository sweep. Daily, manual, and non-draft pull request guard runs scan Actions workflow code plus the highest-risk JavaScript/TypeScript surfaces with high-confidence security queries filtered to high/critical `security-severity`.
|
||||
|
||||
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, `scripts`, `src`, or process-owning bundled plugin runtime paths, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
|
||||
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, or `src`, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
|
||||
|
||||
### Security categories
|
||||
|
||||
@@ -462,7 +462,6 @@ The pull request guard stays light: it only starts for changes under `.github/ac
|
||||
| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints |
|
||||
| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces |
|
||||
| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates |
|
||||
| `/codeql-security-high/process-exec-boundary` | Local shell, process spawn helpers, subprocess-owning bundled plugin runtimes, and workflow script glue |
|
||||
| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces |
|
||||
|
||||
### Platform-specific security shards
|
||||
|
||||
@@ -27,7 +27,7 @@ Use it when you want to:
|
||||
|
||||
- inspect the local requested policy, host approvals file, and effective merge
|
||||
- apply a local preset such as YOLO or deny-all
|
||||
- synchronize local `tools.exec.*` and the local host approvals file
|
||||
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -83,7 +83,7 @@ Precedence is intentional:
|
||||
```bash
|
||||
openclaw approvals set --file ./exec-approvals.json
|
||||
openclaw approvals set --stdin <<'EOF'
|
||||
{ version: 1, defaults: { security: "full", ask: "off", askFallback: "full" } }
|
||||
{ version: 1, defaults: { security: "full", ask: "off" } }
|
||||
EOF
|
||||
openclaw approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||
openclaw approvals set --gateway --file ./exec-approvals.json
|
||||
@@ -137,8 +137,7 @@ Why `tools.exec.host=gateway` in this example:
|
||||
- YOLO is about approvals, not routing.
|
||||
- If you want host exec even when a sandbox is configured, make the host choice explicit with `gateway` or `/exec host=gateway`.
|
||||
|
||||
Omitted `askFallback` defaults to `deny`. Set `askFallback: "full"`
|
||||
explicitly when upgrading a no-UI host that should keep never-prompt behavior.
|
||||
This matches the current host-default YOLO behavior. Tighten it if you want approvals.
|
||||
|
||||
Local shortcut:
|
||||
|
||||
@@ -183,9 +182,7 @@ Targeting notes:
|
||||
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
|
||||
- `--agent` defaults to `"*"`, which applies to all agents.
|
||||
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||
- Approvals files are stored per host in the OpenClaw state dir
|
||||
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
|
||||
`~/.openclaw/exec-approvals.json` when the variable is unset).
|
||||
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -280,14 +280,10 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
|
||||
openclaw browser --browser-profile user tabs
|
||||
openclaw browser create-profile --name chrome-live --driver existing-session
|
||||
openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser"
|
||||
openclaw browser create-profile --name chrome-port --driver existing-session --cdp-url http://127.0.0.1:9222
|
||||
openclaw browser --browser-profile chrome-live tabs
|
||||
```
|
||||
|
||||
The default existing-session path is host-only Chrome MCP auto-connect. If the browser is already
|
||||
running with a DevTools endpoint, pass `--cdp-url` so Chrome MCP attaches to that endpoint instead.
|
||||
For Docker, Browserless, or other remote setups where Chrome MCP semantics are not needed, use a
|
||||
CDP profile.
|
||||
This path is host-only. For Docker, headless servers, Browserless, or other remote setups, use a CDP profile instead.
|
||||
|
||||
Current existing-session limits:
|
||||
|
||||
|
||||
@@ -162,8 +162,7 @@ The node host stores its node id, token, display name, and gateway connection in
|
||||
|
||||
`system.run` is gated by local exec approvals:
|
||||
|
||||
- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or
|
||||
`~/.openclaw/exec-approvals.json` when the variable is unset
|
||||
- `~/.openclaw/exec-approvals.json`
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)
|
||||
|
||||
|
||||
@@ -417,7 +417,7 @@ openclaw plugins inspect <id> --runtime
|
||||
openclaw plugins inspect <id> --json
|
||||
```
|
||||
|
||||
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. JSON output includes the plugin manifest contracts, such as `contracts.agentToolResultMiddleware` and `contracts.trustedToolPolicies`, so operators can audit trusted-surface declarations before enabling or restarting a plugin. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
|
||||
Inspect shows identity, load status, source, manifest capabilities, policy flags, diagnostics, install metadata, bundle capabilities, and any detected MCP or LSP server support without importing plugin runtime by default. Add `--runtime` to load the plugin module and include registered hooks, tools, commands, services, gateway methods, and HTTP routes. Runtime inspection reports missing plugin dependencies directly; installs and repairs stay in `openclaw plugins install`, `openclaw plugins update`, and `openclaw doctor --fix`.
|
||||
|
||||
Plugin-owned CLI commands are usually installed as root `openclaw` command groups, but plugins may also register nested commands under a core parent such as `openclaw nodes`. After `inspect --runtime` shows a command under `cliCommands`, run it at the listed path; for example a plugin that registers `demo-git` can be verified with `openclaw demo-git ping`.
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ updates happen via the package-manager flow in [Updating](/install/updating).
|
||||
```bash
|
||||
openclaw update
|
||||
openclaw update status
|
||||
openclaw update repair
|
||||
openclaw update wizard
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
@@ -77,36 +76,6 @@ Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||
|
||||
## `update repair`
|
||||
|
||||
Rerun update finalization after the core package already changed but later
|
||||
repair work did not finish cleanly. This is the supported recovery path when
|
||||
`openclaw update` installed the new core package but post-core plugin sync,
|
||||
managed npm plugin metadata, registry refresh, or doctor repair still needs to
|
||||
converge.
|
||||
|
||||
```bash
|
||||
openclaw update repair
|
||||
openclaw update repair --channel beta
|
||||
openclaw update repair --json
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `--channel <stable|beta|dev>`: persist the update channel before repair and
|
||||
run plugin convergence against that channel.
|
||||
- `--json`: print machine-readable finalization JSON.
|
||||
- `--timeout <seconds>`: timeout for repair steps (default `1800`).
|
||||
- `--yes`: skip confirmation prompts.
|
||||
- `--no-restart`: accepted for update command parity; repair never restarts the
|
||||
Gateway.
|
||||
|
||||
`openclaw update repair` runs `openclaw doctor --fix`, reloads the repaired
|
||||
config and install records, syncs tracked plugins for the active update channel,
|
||||
updates managed npm plugin installs, repairs missing configured plugin payloads,
|
||||
refreshes the plugin registry, and writes the converged install-record metadata.
|
||||
It does not install a new core package and does not restart the Gateway.
|
||||
|
||||
## `update wizard`
|
||||
|
||||
Interactive flow to pick an update channel and confirm whether to restart the Gateway
|
||||
@@ -130,14 +99,12 @@ install method aligned:
|
||||
missing or older than the current stable release.
|
||||
|
||||
The Gateway core auto-updater (when enabled via config) launches the CLI update path
|
||||
outside the live Gateway request handler. Control-plane `update.run`
|
||||
package-manager updates and supervised git-checkout updates also use a
|
||||
managed-service handoff instead of replacing the package tree or rebuilding
|
||||
`dist/` inside the live Gateway process. The Gateway starts a detached helper,
|
||||
exits, and the helper runs the normal `openclaw update --yes --json` CLI path
|
||||
from outside the Gateway process tree. If that handoff is unavailable,
|
||||
`update.run` returns a structured response with the safe shell command to run
|
||||
manually.
|
||||
outside the live Gateway request handler. Control-plane `update.run` package-manager
|
||||
updates also use a managed-service handoff instead of replacing the package tree
|
||||
inside the live Gateway process. The Gateway starts a detached helper, exits,
|
||||
and the helper runs the normal `openclaw update --yes --json` CLI path from
|
||||
outside the Gateway process tree. If that handoff is unavailable, `update.run`
|
||||
returns a structured response with the safe shell command to run manually.
|
||||
|
||||
For package-manager installs, `openclaw update` resolves the target package
|
||||
version before invoking the package manager. npm global installs use a staged
|
||||
@@ -152,33 +119,29 @@ installed OpenClaw build while leaving full plugin-command completion rebuilds t
|
||||
explicit `openclaw completion --write-state` runs.
|
||||
|
||||
When a local managed Gateway service is installed and restart is enabled,
|
||||
package-manager and git-checkout updates stop the running service before
|
||||
replacing the package tree or mutating the checkout/build output. The updater
|
||||
then refreshes the service metadata from the updated install, restarts the
|
||||
service, and verifies the restarted Gateway before reporting
|
||||
`Gateway: restarted and verified.`. Package-manager updates additionally verify
|
||||
the restarted Gateway reports the expected package version; git-checkout updates
|
||||
verify gateway health and service readiness after the rebuild. On macOS, the
|
||||
post-update check also verifies the LaunchAgent is loaded/running for the active
|
||||
profile and the configured loopback port is healthy. If the plist is installed
|
||||
but launchd is not supervising it, OpenClaw re-bootstraps the LaunchAgent
|
||||
automatically, then reruns the health/version/channel readiness checks. A fresh
|
||||
bootstrap loads the RunAtLoad job directly, so update recovery does not
|
||||
immediately `kickstart -k` the newly spawned Gateway. If the Gateway still does
|
||||
not become healthy, the command exits non-zero and prints the restart log path
|
||||
plus explicit restart, reinstall, and package rollback instructions. If restart
|
||||
cannot run, the command prints `Gateway: restart skipped (...)` or
|
||||
`Gateway: restart failed: ...` with a manual `openclaw gateway restart` hint.
|
||||
With `--no-restart`, package replacement or git rebuild still runs but the
|
||||
managed service is not stopped or restarted, so the running Gateway may keep old
|
||||
code until you restart it manually.
|
||||
package-manager updates stop the running service before replacing the package
|
||||
tree, then refresh the service metadata from the updated install, restart the
|
||||
service, and verify the restarted Gateway reports the expected version before
|
||||
reporting `Gateway: restarted and verified.`. On macOS, the post-update check
|
||||
also verifies the LaunchAgent is loaded/running for the active profile and the
|
||||
configured loopback port is healthy. If the plist is installed but launchd is
|
||||
not supervising it, OpenClaw re-bootstraps the LaunchAgent automatically, then
|
||||
reruns the health/version/channel readiness checks. A fresh bootstrap loads the
|
||||
RunAtLoad job directly, so update recovery does not immediately `kickstart -k`
|
||||
the newly spawned Gateway. If the Gateway still does not become healthy, the
|
||||
command exits non-zero and prints the restart log path plus explicit restart,
|
||||
reinstall, and package rollback instructions. If restart cannot run, the command
|
||||
prints `Gateway: restart skipped (...)` or `Gateway: restart failed: ...` with a
|
||||
manual `openclaw gateway restart` hint. With `--no-restart`,
|
||||
package replacement still runs but the managed service is not stopped or
|
||||
restarted, so the running Gateway may keep old code until you restart it
|
||||
manually.
|
||||
|
||||
### Control-plane response shape
|
||||
|
||||
When `update.run` is invoked through the Gateway control plane on a
|
||||
package-manager install or supervised git checkout, the handler reports the
|
||||
handoff initiation separately from the CLI update that continues after the
|
||||
Gateway exits:
|
||||
package-manager install, the handler reports the handoff initiation separately
|
||||
from the CLI update that continues after the Gateway exits:
|
||||
|
||||
- `ok: true`, `result.status: "skipped"`,
|
||||
`result.reason: "managed-service-handoff-started"`, and
|
||||
@@ -187,11 +150,8 @@ Gateway exits:
|
||||
`openclaw update --yes --json` outside the live service process.
|
||||
- `ok: false`, `result.reason: "managed-service-handoff-unavailable"`, and
|
||||
`handoff.status: "unavailable"` mean OpenClaw could not find a supervising
|
||||
service boundary and durable service identity for a safe handoff. For
|
||||
example, systemd handoff requires the OpenClaw unit identity
|
||||
(`OPENCLAW_SYSTEMD_UNIT`), not only ambient systemd process markers. The
|
||||
response includes `handoff.command`, the shell command to run from outside the
|
||||
Gateway.
|
||||
service boundary for a safe handoff. The response includes
|
||||
`handoff.command`, the shell command to run from outside the Gateway.
|
||||
- `ok: false`, `result.reason: "managed-service-handoff-failed"` means the
|
||||
Gateway tried to create the handoff but could not spawn the detached helper.
|
||||
|
||||
@@ -202,8 +162,8 @@ health checks complete. During the handoff, the sentinel can carry
|
||||
restarted Gateway keeps polling it and only fires the continuation after the CLI
|
||||
has verified service health and rewritten the sentinel with the final `ok`
|
||||
result. `openclaw status` and `openclaw status --all` show an `Update restart`
|
||||
row while that sentinel is pending or failed, and `update.status` refreshes and
|
||||
returns the latest sentinel.
|
||||
row while that sentinel is pending or failed, and `update.status` returns the
|
||||
latest cached sentinel.
|
||||
|
||||
## Git checkout flow
|
||||
|
||||
@@ -258,9 +218,9 @@ If an exact pinned npm plugin update resolves to an artifact whose integrity dif
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw update repair`.
|
||||
Post-update plugin sync failures that are scoped to a managed plugin and that the sync path can route around (e.g. an unreachable npm registry for a non-essential plugin) are reported as warnings after the core update succeeds. The JSON result keeps the top-level update `status: "ok"` and reports `postUpdate.plugins.status: "warning"` with `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` guidance. Unexpected updater or sync exceptions still fail the update result. Fix the plugin install or update error, then rerun `openclaw doctor --fix` or `openclaw update`.
|
||||
|
||||
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw update repair` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
|
||||
After the per-plugin sync step, `openclaw update` runs a mandatory **post-core convergence** pass before the gateway is restarted: it repairs missing configured plugin payloads, validates each _active_ tracked install record on disk, and statically verifies its `package.json` is parseable (and any explicitly-declared `main` exists). Failures from this pass — and an invalid OpenClaw config snapshot — return `postUpdate.plugins.status: "error"` and flip the top-level update `status` to `"error"`, so `openclaw update` exits non-zero and the gateway is _not_ restarted with an unverified plugin set. The error includes structured `postUpdate.plugins.warnings[].guidance` lines pointing at `openclaw doctor --fix` and `openclaw plugins inspect <id> --runtime --json` for follow-up. Disabled plugin entries and records that are not trusted-source-linked official sync targets are skipped here, mirroring the `skipDisabledPlugins` policy used by the missing-payload check, so a stale disabled plugin record cannot block an otherwise valid update.
|
||||
|
||||
When the updated Gateway starts, plugin loading is verify-only: startup does not
|
||||
run package managers or mutate dependency trees. Package-manager `update.run`
|
||||
|
||||
@@ -35,7 +35,6 @@ openclaw wiki status
|
||||
openclaw wiki doctor
|
||||
openclaw wiki init
|
||||
openclaw wiki ingest ./notes/alpha.md
|
||||
openclaw wiki okf import ./knowledge-catalog/okf/bundles/ga4
|
||||
openclaw wiki compile
|
||||
openclaw wiki lint
|
||||
openclaw wiki search "alpha"
|
||||
@@ -105,31 +104,6 @@ Notes:
|
||||
- imported source pages keep provenance in frontmatter
|
||||
- auto-compile can run after ingest when enabled
|
||||
|
||||
### `wiki okf import <path>`
|
||||
|
||||
Import an unpacked Open Knowledge Format bundle into wiki concept pages.
|
||||
|
||||
The importer reads every non-reserved `.md` concept document in the OKF
|
||||
directory tree, requires a non-empty `type` field, and treats unknown OKF
|
||||
`type` values as generic concepts. Reserved OKF `index.md` and `log.md` files
|
||||
are not imported as concepts.
|
||||
|
||||
Imported pages are flattened under `concepts/` so existing wiki compile,
|
||||
search, get, digest, and dashboard flows see them immediately. The original OKF
|
||||
concept ID, `type`, `resource`, `tags`, timestamp, source path, and full
|
||||
frontmatter are preserved in the page frontmatter. Internal OKF markdown links
|
||||
are rewritten to the generated wiki pages; broken or external links are left
|
||||
unchanged.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw wiki okf import ./bundles/ga4
|
||||
openclaw wiki okf import ./bundles/ga4 --json
|
||||
openclaw wiki search "BigQuery Table" --mode source-evidence --json
|
||||
openclaw wiki get <path-from-json-result>
|
||||
```
|
||||
|
||||
### `wiki compile`
|
||||
|
||||
Rebuild indexes, related blocks, dashboards, and compiled digests.
|
||||
@@ -259,8 +233,6 @@ These require the official `obsidian` CLI on `PATH` when
|
||||
- Use `wiki lint` before trusting contradictory or low-confidence content.
|
||||
- Use `wiki compile` after bulk imports or source changes when you want fresh
|
||||
dashboards and compiled digests immediately.
|
||||
- Use `wiki okf import` when a data catalog, documentation export, or agent
|
||||
enrichment pipeline already emits OKF markdown bundles.
|
||||
- Use `wiki bridge import` when bridge mode depends on newly exported memory
|
||||
artifacts.
|
||||
|
||||
|
||||
@@ -62,12 +62,10 @@ present.
|
||||
`build`. Gateway startup does not initialize QMD by default, so cold boot
|
||||
avoids importing the memory runtime or creating the long-lived watcher before
|
||||
memory is first used.
|
||||
- If you want QMD initialized at gateway start anyway, set
|
||||
`memory.qmd.update.startup` to `idle` or `immediate`. With
|
||||
`memory.qmd.update.onBoot: true`, startup runs the initial refresh. With
|
||||
`onBoot: false`, startup skips that immediate refresh but still opens the
|
||||
long-lived manager when update or embed intervals are configured, so QMD can
|
||||
own its regular watcher and timers.
|
||||
- If you want a gateway-start refresh anyway, set
|
||||
`memory.qmd.update.startup` to `idle` or `immediate`. The opt-in startup
|
||||
refresh uses a one-shot QMD subprocess path instead of creating the full
|
||||
long-lived in-process watcher.
|
||||
- Searches use the configured `searchMode` (default: `search`; also supports
|
||||
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
|
||||
vector readiness probes and embedding maintenance in that mode. If a mode
|
||||
|
||||
@@ -309,7 +309,7 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-ultra-550b-a55b` |
|
||||
| NovitaAI | `novita` | `NOVITA_API_KEY` | `novita/deepseek/deepseek-v3-0324` |
|
||||
| [Ollama Cloud](/providers/ollama-cloud) | `ollama-cloud` | `OLLAMA_API_KEY` | `ollama-cloud/kimi-k2.6` |
|
||||
| OpenRouter | `openrouter` | OpenRouter OAuth or `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
| Qianfan | `qianfan` | `QIANFAN_API_KEY` | `qianfan/deepseek-v3.2` |
|
||||
| Qwen Cloud | `qwen` | `QWEN_API_KEY` / `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | `qwen/qwen3.5-plus` |
|
||||
| [Qwen OAuth](/providers/qwen-oauth) | `qwen-oauth` | `QWEN_API_KEY` | `qwen-oauth/qwen3.5-plus` |
|
||||
@@ -368,7 +368,6 @@ Kimi K2 model IDs:
|
||||
[//]: # "moonshot-kimi-k2-model-refs:start"
|
||||
|
||||
- `moonshot/kimi-k2.6`
|
||||
- `moonshot/kimi-k2.7-code`
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
|
||||
@@ -374,7 +374,7 @@ The implicit default set always covers canary, mention gating, native command re
|
||||
Output artifacts:
|
||||
|
||||
- `telegram-qa-report.md`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks, including profile, coverage, provider, channel, artifacts, result, and RTT fields.
|
||||
- `telegram-qa-summary.json` - includes per-reply RTT (driver send → observed SUT reply) starting with the canary.
|
||||
- `telegram-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1`.
|
||||
|
||||
Package RTT comparison uses the same Telegram credential contract while keeping
|
||||
@@ -447,7 +447,7 @@ pnpm openclaw qa discord \
|
||||
Output artifacts:
|
||||
|
||||
- `discord-qa-report.md`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks.
|
||||
- `discord-qa-summary.json`
|
||||
- `discord-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_DISCORD_CAPTURE_CONTENT=1`.
|
||||
- `discord-qa-reaction-timelines.json` and `discord-status-reactions-tool-only-timeline.png` when the status-reaction scenario runs.
|
||||
|
||||
@@ -495,7 +495,7 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts`):
|
||||
Output artifacts:
|
||||
|
||||
- `slack-qa-report.md`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks.
|
||||
- `slack-qa-summary.json`
|
||||
- `slack-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_SLACK_CAPTURE_CONTENT=1`.
|
||||
- `approval-checkpoints/` - only when Mantis sets
|
||||
`OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR`; contains checkpoint JSON,
|
||||
@@ -740,7 +740,7 @@ poll and upload-file coverage run through deterministic gateway `poll` and
|
||||
Output artifacts:
|
||||
|
||||
- `whatsapp-qa-report.md`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks.
|
||||
- `whatsapp-qa-summary.json`
|
||||
- `whatsapp-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1`.
|
||||
|
||||
### Convex credential pool
|
||||
@@ -787,10 +787,9 @@ the source of truth for one test run and should define:
|
||||
- docs and code refs
|
||||
- optional plugin requirements
|
||||
- optional gateway config patch
|
||||
- an executable `qa-flow` block for flow scenarios, or `execution.kind`/`execution.path`
|
||||
for Vitest and Playwright scenarios
|
||||
- the executable `qa-flow`
|
||||
|
||||
The reusable runtime surface that backs `qa-flow` blocks is allowed to stay generic
|
||||
The reusable runtime surface that backs `qa-flow` is allowed to stay generic
|
||||
and cross-cutting. For example, markdown scenarios can combine transport-side
|
||||
helpers with browser-side helpers that drive the embedded Control UI through the
|
||||
Gateway `browser.request` seam without adding a special-case runner.
|
||||
@@ -916,7 +915,6 @@ The report should answer:
|
||||
For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output).
|
||||
When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match <query>`.
|
||||
The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets.
|
||||
Every `qa suite` scenario execution writes a `qa-evidence.json` artifact. Flow scenarios also write `qa-suite-summary.json` for existing suite/report tooling; scenarios that declare `execution.kind: vitest` or `execution.kind: playwright` run the matching test path and write `qa-vitest-report.md` or `qa-playwright-report.md` plus per-scenario logs.
|
||||
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
|
||||
|
||||
For character and style checks, run the same scenario across multiple live model
|
||||
|
||||
@@ -161,7 +161,6 @@ Telegram:
|
||||
|
||||
- Uses `sendMessage` + `editMessageText` preview updates across DMs and group/topics.
|
||||
- Final text edits the active preview in place; long finals reuse that message for the first chunk and send only the remaining chunks.
|
||||
- `block` mode rotates the preview into a new message at `streaming.preview.chunk.maxChars` (default 800, capped at Telegram's 4096 edit limit); other modes grow one preview up to 4096 characters.
|
||||
- `progress` mode keeps tool progress in an editable status draft, clears that draft at completion, and sends the final answer through normal delivery.
|
||||
- If the final edit fails before the completed text is confirmed, OpenClaw uses normal final delivery and cleans up the stale preview.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
|
||||
@@ -30,23 +30,6 @@ title: "Usage tracking"
|
||||
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: "Usage" section under Context (only if available).
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
Set `messages.usageTemplate` to customize the per-response `/usage full`
|
||||
footer. The value can be an inline template object or a JSON file path:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": {
|
||||
"usageTemplate": "~/.openclaw/usage-footer.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
|
||||
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
|
||||
unreadable, invalid, or empty templates fall back to the built-in usage line.
|
||||
|
||||
## Providers + credentials
|
||||
|
||||
- **Anthropic (Claude)**: OAuth tokens in auth profiles.
|
||||
|
||||
@@ -1375,6 +1375,7 @@
|
||||
"clawhub/cli",
|
||||
"clawhub/publishing",
|
||||
"clawhub/skill-format",
|
||||
"clawhub/soul-format",
|
||||
"clawhub/auth",
|
||||
"clawhub/telemetry",
|
||||
"clawhub/troubleshooting"
|
||||
|
||||
@@ -634,7 +634,7 @@ Periodic heartbeat runs.
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
provider: "my-provider", // id of a registered compaction provider plugin (optional)
|
||||
timeoutSeconds: 180,
|
||||
timeoutSeconds: 900,
|
||||
reserveTokensFloor: 24000,
|
||||
keepRecentTokens: 50000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
@@ -661,7 +661,7 @@ Periodic heartbeat runs.
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `provider`: id of a registered compaction provider plugin. When set, the provider's `summarize()` is called instead of built-in LLM summarization. Falls back to built-in on failure. Setting a provider forces `mode: "safeguard"`. See [Compaction](/concepts/compaction).
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `180`.
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
|
||||
- `keepRecentTokens`: agent cut-point budget for keeping the most recent transcript tail verbatim. Manual `/compact` honors this when explicitly set; otherwise manual compaction is a hard checkpoint.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
|
||||
@@ -615,7 +615,6 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
|
||||
remoteAttachmentRoots: ["/Users/*/Library/Messages/Attachments"],
|
||||
mediaMaxMb: 16,
|
||||
service: "auto",
|
||||
sendTransport: "auto",
|
||||
region: "US",
|
||||
actions: {
|
||||
reactions: true,
|
||||
@@ -638,7 +637,6 @@ Before relying on an SSH wrapper for production sends, verify an outbound `imsg
|
||||
- `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`).
|
||||
- SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.
|
||||
- `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes.
|
||||
- `channels.imessage.sendTransport`: preferred `imsg` RPC send transport for normal outbound replies. `auto` (default) uses the IMCore bridge for existing chats when it is running, then falls back to AppleScript; `bridge` requires private-API delivery; `applescript` forces the public Messages automation path.
|
||||
- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`.
|
||||
- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns.
|
||||
- Inbound recovery after a bridge/gateway restart is automatic (GUID dedupe plus a stale-backlog age fence). Existing `channels.imessage.catchup.enabled: true` configs are still honored as a deprecated compatibility profile.
|
||||
|
||||
@@ -493,8 +493,6 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
|
||||
- `extraDirs` scans the sibling repo as an explicit skill root.
|
||||
- `allowSymlinkTargets` lets symlinked skill folders resolve into that trusted
|
||||
real target root without allowing arbitrary symlink escapes.
|
||||
- To let Skill Workshop apply write through the same trusted symlink target,
|
||||
set `skills.workshop.allowSymlinkTargetWrites: true`.
|
||||
|
||||
## Common patterns
|
||||
|
||||
|
||||
@@ -200,9 +200,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
nodeManager: "npm", // npm | pnpm | yarn | bun
|
||||
allowUploadedArchives: false,
|
||||
},
|
||||
workshop: {
|
||||
allowSymlinkTargetWrites: false,
|
||||
},
|
||||
entries: {
|
||||
"image-lab": {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
|
||||
@@ -219,8 +216,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
- `load.extraDirs`: extra shared skill roots (lowest precedence).
|
||||
- `load.allowSymlinkTargets`: trusted real target roots that skill symlinks may
|
||||
resolve into when the link lives outside its configured source root.
|
||||
- `workshop.allowSymlinkTargetWrites`: allows Skill Workshop apply to write
|
||||
through already-trusted symlink targets (default: false).
|
||||
- `install.preferBrew`: when true, prefer Homebrew installers when `brew` is
|
||||
available before falling back to other installer kinds.
|
||||
- `install.nodeManager`: node installer preference for `metadata.openclaw.install`
|
||||
@@ -447,17 +442,12 @@ See [Inferred commitments](/concepts/commitments).
|
||||
the selected host or through a connected browser node.
|
||||
- `existing-session` profiles can set `userDataDir` to target a specific
|
||||
Chromium-based browser profile such as Brave or Edge.
|
||||
- `existing-session` profiles can set `cdpUrl` when Chrome is already running
|
||||
behind a DevTools HTTP(S) discovery endpoint or direct WS(S) endpoint. In that
|
||||
mode OpenClaw passes the endpoint to Chrome MCP instead of using auto-connect;
|
||||
`userDataDir` is ignored for Chrome MCP launch arguments.
|
||||
- `existing-session` profiles keep the current Chrome MCP route limits:
|
||||
snapshot/ref-driven actions instead of CSS-selector targeting, one-file upload
|
||||
hooks, no dialog timeout overrides, no `wait --load networkidle`, and no
|
||||
`responsebody`, PDF export, download interception, or batch actions.
|
||||
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; set
|
||||
`cdpUrl` explicitly only for remote CDP profiles or existing-session endpoint
|
||||
attach.
|
||||
- Local managed `openclaw` profiles auto-assign `cdpPort` and `cdpUrl`; only
|
||||
set `cdpUrl` explicitly for remote CDP.
|
||||
- Local managed profiles can set `executablePath` to override the global
|
||||
`browser.executablePath` for that profile. Use this to run one profile in
|
||||
Chrome and another in Brave.
|
||||
|
||||
@@ -42,21 +42,6 @@ health commands above for live connectivity checks.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
|
||||
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
|
||||
|
||||
## Uptime monitoring
|
||||
|
||||
External uptime monitoring services should use the dedicated `/health` endpoint, not `/v1/chat/completions`.
|
||||
|
||||
- **DO use:** `GET /health` — instant response, no session created, no LLM call, returns `{"ok":true,"status":"live"}`
|
||||
- **DON'T use:** `/v1/chat/completions` for health checks — each request creates a full agent session with skill snapshot, context assembly, and LLM calls
|
||||
|
||||
When no `x-openclaw-session-key` header or `user` field is provided, `/v1/chat/completions` generates a new random session for each request. Monitoring services that ping every 15 minutes create ~96 sessions/day, each consuming 4–22KB. Over time this causes session store bloat and can lead to context window overflow.
|
||||
|
||||
### Monitoring service setup examples
|
||||
|
||||
- **BetterStack:** Set health check URL to `https://<your-gateway-host>:<port>/health`
|
||||
- **UptimeRobot:** Add a new HTTP monitor with URL `https://<your-gateway-host>:<port>/health`
|
||||
- **Generic:** Any HTTP GET to `/health` returns 200 with `{"ok":true}` when the gateway is healthy
|
||||
|
||||
## When something fails
|
||||
|
||||
- `logged out` or status 409–515 → relink with `openclaw channels logout` then `openclaw channels login`.
|
||||
|
||||
@@ -75,7 +75,6 @@ Auth matrix:
|
||||
- honor `x-openclaw-scopes` when the header is present
|
||||
- fall back to the normal operator default scope set when the header is absent
|
||||
- only lose owner semantics when the caller explicitly narrows scopes and omits `operator.admin`
|
||||
- require `operator.admin` for owner-level request controls such as `x-openclaw-model`
|
||||
|
||||
See [Security](/gateway/security) and [Remote access](/gateway/remote).
|
||||
|
||||
@@ -97,7 +96,7 @@ OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provi
|
||||
|
||||
Optional request headers:
|
||||
|
||||
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
|
||||
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
|
||||
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
|
||||
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
|
||||
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
|
||||
@@ -179,7 +178,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="How do I override the backend model?">
|
||||
Use `x-openclaw-model`. This is an owner-level override: it works with the Gateway shared-secret bearer token/password path, and it requires `operator.admin` on identity-bearing HTTP paths such as trusted proxy auth.
|
||||
Use `x-openclaw-model`.
|
||||
|
||||
Examples:
|
||||
`x-openclaw-model: openai/gpt-5.4`
|
||||
@@ -192,7 +191,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
|
||||
`/v1/embeddings` uses the same agent-target `model` ids.
|
||||
|
||||
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
|
||||
When you need a specific embedding model, send it in `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`.
|
||||
When you need a specific embedding model, send it in `x-openclaw-model`.
|
||||
Without that header, the request passes through to the selected agent's normal embedding setup.
|
||||
|
||||
</Accordion>
|
||||
@@ -286,7 +285,7 @@ Expected behavior:
|
||||
|
||||
- `GET /v1/models` should list `openclaw/default`
|
||||
- Open WebUI should use `openclaw/default` as the chat model id
|
||||
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`
|
||||
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
|
||||
|
||||
Quick smoke:
|
||||
|
||||
@@ -371,7 +370,7 @@ Notes:
|
||||
|
||||
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
|
||||
- `openclaw/default` is always present so one stable id works across environments.
|
||||
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field. On identity-bearing HTTP auth paths, this header requires `operator.admin`.
|
||||
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
|
||||
- `/v1/embeddings` supports `input` as a string or array of strings.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -411,8 +411,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `config.apply` validates + replaces the full config payload.
|
||||
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
|
||||
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
|
||||
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. An unavailable handoff means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
|
||||
- `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
|
||||
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates from the control plane use a detached managed-service handoff instead of replacing the package tree inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
|
||||
- `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available.
|
||||
- `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -93,7 +93,7 @@ exhaustive):
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
|
||||
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
|
||||
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no |
|
||||
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no |
|
||||
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
|
||||
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
|
||||
| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no |
|
||||
|
||||
@@ -951,7 +951,7 @@ Important boundary note:
|
||||
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` as full-access operator secrets for that gateway.
|
||||
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
|
||||
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
|
||||
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set. Owner-level OpenAI-compatible headers such as `x-openclaw-model` require `operator.admin` when scopes are narrowed.
|
||||
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
|
||||
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
|
||||
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user