mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-14 01:58:47 +08:00
Compare commits
1 Commits
codex/secu
...
fix/talk-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebaecbb7bd |
@@ -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*"
|
||||
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -293,10 +293,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/lobster/**"
|
||||
"extensions: llama-cpp":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/llama-cpp/**"
|
||||
"extensions: memory-core":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -2093,7 +2093,7 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.android-sdk
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
|
||||
key: ${{ runner.os }}-android-sdk-v1-cmdline-12266719-platform-36-build-tools-36.0.0
|
||||
restore-keys: |
|
||||
${{ runner.os }}-android-sdk-v1-
|
||||
|
||||
@@ -2101,7 +2101,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
||||
CMDLINE_TOOLS_VERSION="14742923"
|
||||
CMDLINE_TOOLS_VERSION="12266719"
|
||||
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
||||
|
||||
@@ -2123,7 +2123,7 @@ jobs:
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-37.0" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
- name: Run Android ${{ matrix.task }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
@@ -46,6 +46,6 @@ jobs:
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
|
||||
60
.github/workflows/codeql-critical-quality.yml
vendored
60
.github/workflows/codeql-critical-quality.yml
vendored
@@ -342,13 +342,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-core-auth-secrets-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/core-auth-secrets"
|
||||
|
||||
@@ -365,13 +365,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-config-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/config-boundary"
|
||||
|
||||
@@ -388,13 +388,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-gateway-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/gateway-runtime-boundary"
|
||||
|
||||
@@ -411,13 +411,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-channel-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/channel-runtime-boundary"
|
||||
|
||||
@@ -460,7 +460,7 @@ jobs:
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-network-runtime-boundary-critical-quality.yml
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
category: "/codeql-critical-quality/network-runtime-boundary"
|
||||
@@ -518,13 +518,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-agent-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/agent-runtime-boundary"
|
||||
|
||||
@@ -541,13 +541,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-mcp-process-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/mcp-process-runtime-boundary"
|
||||
|
||||
@@ -564,13 +564,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-memory-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/memory-runtime-boundary"
|
||||
|
||||
@@ -587,13 +587,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-session-diagnostics-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/session-diagnostics-boundary"
|
||||
|
||||
@@ -610,13 +610,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-reply-runtime-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-reply-runtime"
|
||||
|
||||
@@ -633,13 +633,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-provider-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/provider-runtime-boundary"
|
||||
|
||||
@@ -655,13 +655,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-ui-control-plane-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/ui-control-plane"
|
||||
|
||||
@@ -677,13 +677,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-web-media-runtime-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/web-media-runtime-boundary"
|
||||
|
||||
@@ -700,13 +700,13 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-boundary"
|
||||
|
||||
@@ -723,12 +723,12 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-sdk-package-contract-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-sdk-package-contract"
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/upload-sarif@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
51
.github/workflows/codeql.yml
vendored
51
.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
|
||||
@@ -148,12 +101,12 @@ jobs:
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4
|
||||
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4
|
||||
with:
|
||||
category: "/codeql-security-high/${{ matrix.category }}"
|
||||
|
||||
65
.github/workflows/docker-release.yml
vendored
65
.github/workflows/docker-release.yml
vendored
@@ -88,30 +88,11 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -298,30 +279,11 @@ jobs:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -506,7 +468,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -599,30 +561,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Pre-pull BuildKit image
|
||||
shell: bash
|
||||
env:
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3 4; do
|
||||
if docker pull "${BUILDKIT_IMAGE}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" == "4" ]]; then
|
||||
echo "::error::Failed to pull ${BUILDKIT_IMAGE} after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 10))
|
||||
echo "BuildKit image pull failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "${sleep_seconds}"
|
||||
done
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
16
.github/workflows/install-smoke.yml
vendored
16
.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
|
||||
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -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
|
||||
|
||||
@@ -311,7 +311,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -417,7 +417,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -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
|
||||
|
||||
@@ -503,7 +503,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -29,14 +29,14 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
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
|
||||
|
||||
|
||||
2
.github/workflows/mantis-discord-smoke.yml
vendored
2
.github/workflows/mantis-discord-smoke.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "0bf06e953fdda290799fc9fb9244a8f67fdae593";
|
||||
@@ -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" \
|
||||
@@ -590,7 +581,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
|
||||
@@ -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"
|
||||
@@ -612,7 +603,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Mantis candidate pnpm store
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "pull_request_target") {
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const eventName = context.eventName;
|
||||
@@ -709,7 +709,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
12
.github/workflows/mantis-telegram-live.yml
vendored
12
.github/workflows/mantis-telegram-live.yml
vendored
@@ -68,7 +68,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const eventName = context.eventName;
|
||||
@@ -327,7 +327,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Cache Mantis candidate pnpm store
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.local/share/pnpm/store
|
||||
@@ -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"
|
||||
@@ -573,7 +573,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Remove workflow eyes reaction
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
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,72 +1497,37 @@ 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
|
||||
|
||||
- name: Build and push bare Docker E2E image
|
||||
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.bare_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_cmd=(
|
||||
docker buildx build
|
||||
--file ./scripts/e2e/Dockerfile
|
||||
--target bare
|
||||
--platform linux/amd64
|
||||
--tag "$IMAGE_REF"
|
||||
--sbom=true
|
||||
--provenance=mode=max
|
||||
--push
|
||||
.
|
||||
)
|
||||
for attempt in 1 2 3 4; do
|
||||
if "${build_cmd[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "4" ]]; then
|
||||
echo "::error::Failed to build Docker E2E bare image after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 20))
|
||||
echo "Docker E2E bare image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: bare
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.bare_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Build and push functional Docker E2E image
|
||||
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_REF: ${{ steps.image.outputs.functional_image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
build_cmd=(
|
||||
docker buildx build
|
||||
--file ./scripts/e2e/Dockerfile
|
||||
--target functional
|
||||
--build-context openclaw_package=.artifacts/docker-e2e-package
|
||||
--platform linux/amd64
|
||||
--tag "$IMAGE_REF"
|
||||
--sbom=true
|
||||
--provenance=mode=max
|
||||
--push
|
||||
.
|
||||
)
|
||||
for attempt in 1 2 3 4; do
|
||||
if "${build_cmd[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$attempt" == "4" ]]; then
|
||||
echo "::error::Failed to build Docker E2E functional image after ${attempt} attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_seconds=$((attempt * 20))
|
||||
echo "Docker E2E functional image build failed; retrying in ${sleep_seconds}s (${attempt}/4)."
|
||||
sleep "$sleep_seconds"
|
||||
done
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: functional
|
||||
build-contexts: |
|
||||
openclaw_package=.artifacts/docker-e2e-package
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.image.outputs.functional_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
prepare_live_test_image:
|
||||
needs: validate_selected_ref
|
||||
@@ -1593,11 +1558,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 +1582,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 +1594,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 +1710,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 +1798,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 +1884,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
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -53,7 +53,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
scripts/run-opengrep.sh --changed --sarif --error
|
||||
|
||||
- name: Upload SARIF to GitHub Code Scanning
|
||||
uses: github/codeql-action/upload-sarif@v4.36.2
|
||||
uses: github/codeql-action/upload-sarif@v4.36.1
|
||||
# Only upload if the scan actually produced a SARIF file.
|
||||
if: always() && hashFiles('.opengrep-out/precise.sarif') != ''
|
||||
with:
|
||||
|
||||
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
|
||||
383
.github/workflows/plugin-clawhub-release.yml
vendored
383
.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,15 +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
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
@@ -42,7 +33,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: "facf20ceb6cc459e2872d941e71335a784bbc55c"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
@@ -52,15 +45,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
|
||||
@@ -69,6 +56,12 @@ jobs:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
env:
|
||||
@@ -91,27 +84,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 +97,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
|
||||
@@ -132,12 +107,6 @@ jobs:
|
||||
echo "Plugin ClawHub publishes 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:
|
||||
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
|
||||
@@ -184,78 +153,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 +192,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 +216,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,12 +235,106 @@ 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
|
||||
|
||||
pack_plugins_clawhub_artifacts:
|
||||
needs: [preview_plugins_clawhub, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
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}"
|
||||
|
||||
publish_plugins_clawhub:
|
||||
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
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
@@ -338,21 +365,115 @@ 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
|
||||
- name: Write ClawHub token config
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${CLAWHUB_TOKEN}" ]]; then
|
||||
echo "No CLAWHUB_TOKEN secret configured; publish will rely on GitHub OIDC trusted publishing."
|
||||
exit 0
|
||||
fi
|
||||
node --input-type=module <<'EOF'
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const path = join(process.env.RUNNER_TEMP, "clawhub-config.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
registry: process.env.CLAWHUB_REGISTRY,
|
||||
token: process.env.CLAWHUB_TOKEN,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
console.log(path);
|
||||
EOF
|
||||
echo "CLAWHUB_CONFIG_PATH=${RUNNER_TEMP}/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check ClawHub package version
|
||||
id: clawhub_package_version
|
||||
env:
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
encoded_name="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_NAME ?? ""))')"
|
||||
encoded_version="$(node -e 'console.log(encodeURIComponent(process.env.PACKAGE_VERSION ?? ""))')"
|
||||
url="${CLAWHUB_REGISTRY%/}/api/v1/packages/${encoded_name}/versions/${encoded_version}"
|
||||
status=""
|
||||
for attempt in $(seq 1 8); do
|
||||
status="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' "${url}")"
|
||||
if [[ "${status}" == "404" || "${status}" =~ ^2 ]]; then
|
||||
break
|
||||
fi
|
||||
if [[ "${status}" == "429" || "${status}" =~ ^5 ]]; then
|
||||
echo "ClawHub availability check returned ${status} for ${PACKAGE_NAME}@${PACKAGE_VERSION}; retrying (${attempt}/8)."
|
||||
sleep 60
|
||||
continue
|
||||
fi
|
||||
break
|
||||
done
|
||||
if [[ "${status}" =~ ^2 ]]; then
|
||||
echo "${PACKAGE_NAME}@${PACKAGE_VERSION} is already published on ClawHub."
|
||||
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${status}" != "404" ]]; then
|
||||
echo "Unexpected ClawHub response (${status}) for ${PACKAGE_NAME}@${PACKAGE_VERSION}."
|
||||
exit 1
|
||||
fi
|
||||
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
if: steps.clawhub_package_version.outputs.already_published != 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
@@ -360,68 +481,8 @@ jobs:
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
OPENCLAW_CLAWHUB_PACK_OUTPUT_DIR: ${{ runner.temp }}/clawhub-package-artifact
|
||||
run: bash scripts/plugin-clawhub-publish.sh --pack "${PACKAGE_DIR}"
|
||||
run: bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
|
||||
- name: Upload ClawHub package artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.plugin.artifactName }}
|
||||
path: ${{ runner.temp }}/clawhub-package-artifact/*.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
approve_plugins_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'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions:
|
||||
contents: read
|
||||
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."
|
||||
|
||||
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
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
with:
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
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
|
||||
|
||||
verify_published_clawhub_package:
|
||||
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Verify published ClawHub package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
|
||||
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
|
||||
|
||||
34
.github/workflows/qa-live-transports-convex.yml
vendored
34
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -65,7 +65,7 @@ jobs:
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
id: permission
|
||||
uses: actions/github-script@v9
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === "schedule") {
|
||||
@@ -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}.`);
|
||||
|
||||
69
.github/workflows/workflow-sanity.yml
vendored
69
.github/workflows/workflow-sanity.yml
vendored
@@ -34,25 +34,10 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
@@ -93,25 +78,10 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
@@ -220,25 +190,10 @@ jobs:
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
fetch_status="$?"
|
||||
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
if [ "$attempt" = "3" ]; then
|
||||
return "$fetch_status"
|
||||
fi
|
||||
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
fetch_checkout_ref
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -2,39 +2,6 @@
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
## 2026.6.5
|
||||
|
||||
### Highlights
|
||||
@@ -60,11 +27,9 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward `web_fetch`, clarify legacy `openai-codex` auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.
|
||||
- Release/process: switch release trains to `YYYY.M.PATCH` monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at `2026.6.5` after the published beta.
|
||||
- Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)
|
||||
- QQBot: add `/bot-group-allways on|off` slash command (with named-account and default-account support) to toggle whether group messages require an `@mention` before the bot replies, and clear the runtime config snapshot after the write so the new account-level `defaultRequireMention` takes effect immediately without restart. (#91423) Thanks @cxyhhhhh.
|
||||
|
||||
### 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.
|
||||
@@ -74,12 +39,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, service env planning skips unresolved placeholders that would mask state-dir `.env` values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.
|
||||
- Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)
|
||||
- Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on `globalThis`; Feishu streaming cards preserve full merged content; voice-call tracks Twilio streams after connect; ClickClack reply tools respect `toolsAllow`. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, and @sahibzada-allahyar.
|
||||
- Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.
|
||||
- Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.
|
||||
- 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.
|
||||
|
||||
127
appcast.xml
127
appcast.xml
@@ -2,86 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.5</title>
|
||||
<pubDate>Tue, 09 Jun 2026 19:06:49 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606000590</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.5</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.5</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>QQBot now strips model reasoning/thinking scaffolding before native delivery, preventing raw <code><thinking></code> content from leaking into channel replies. (#89913, #90132) Thanks @openperf.</li>
|
||||
<li>MCP tool results now coerce <code>resource_link</code>, <code>resource</code>, <code>audio</code>, malformed image, and future non-text/image blocks at the materialize boundary, preventing Anthropic 400s and poisoned session history after a tool returns richer MCP content. (#90710, #90728) Thanks @RanSHammer and @849261680.</li>
|
||||
<li>Anthropic extended-thinking sessions recover after prompt-cache expiry or Gateway restart because stream start events wait for <code>message_start</code>, letting pre-generation signature errors trigger the existing recovery retry. (#90667, #90697) Thanks @openperf.</li>
|
||||
<li>Parallel is now a bundled <code>web_search</code> provider with <code>PARALLEL_API_KEY</code> discovery, guarded endpoint handling, cache-safe session ids, onboarding picker support, and docs. (#85158) Thanks @NormallyGaussian.</li>
|
||||
<li>Google Vertex ADC users get static catalog rows and runtime model resolution again, while single-provider cooldown recovery and memory adapter status checks are more reliable. (#90506, #90609, #90717, #90816) Thanks @849261680.</li>
|
||||
<li>Matrix can preflight voice notes before mention gating, preserve thread reads/replies through Matrix relations pagination, and carry QA coverage for voice and thread flows. (#78016, #90415)</li>
|
||||
<li>Auth and plugin install state is more durable: auth profiles now live in SQLite, official npm plugin install records keep their trusted pins, and prerelease fallback integrity checks avoid carrying stale integrity forward. (#89102, #88585)</li>
|
||||
<li>Agent, tool, and provider loops are stricter around MCP lease timestamps, prompt-cache tool names, local tool catalogs, unreadable dynamic tools, owner-only HTTP tools, and provider catalog metadata, reducing hidden retries and unsafe exposure. (#91124, #91233, #90022, #90261)</li>
|
||||
<li>macOS node mode no longer silently self-reconnects away from a healthy direct Gateway session, reducing unexpected companion app session churn. (#90668, #90815) Thanks @vrurg.</li>
|
||||
<li>Upgrade and service paths are safer: cron legacy JSON stores migrate during doctor preflight, service env placeholders no longer mask state-dir secrets, WhatsApp startup waits are bounded, and disabled WhatsApp accounts tear down on config reload. (#90072, #90208, #90277, #90488, #90486, #87951, #87965) Thanks @MonkeyLeeT, @sallyom, @mcaxtr, and @MukundaKatta.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Search/providers: add the Parallel bundled web-search plugin, live provider tests, registration contracts, onboarding/docs wiring, and guarded <code>api.parallel.ai/v1/search</code> support. (#85158) Thanks @NormallyGaussian.</li>
|
||||
<li>Matrix/channels: add voice-message preflight and thread-aware read/reply behavior, including Matrix QA scenario wiring and docs for voice-message behavior. (#78016, #90415)</li>
|
||||
<li>Skills/ClawHub: install ClawHub skills backed by GitHub repositories through the resolved install API, download the pinned GitHub commit, keep install-policy checks, and report install telemetry after success. (#90478) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Skills/ClawHub: avoid one filesystem watcher per skill file during refresh, keeping large skill trees from exhausting watcher limits.</li>
|
||||
<li>Google Chat/channels: add native approval card actions and click handling so Google Chat approvals use platform-native cards instead of generic message flow.</li>
|
||||
<li>Mobile: Android provider/model screens now surface expiring, unavailable, unresolved, and attention states more clearly, Android adds theme mode selection, and iOS settings and Talk tabs keep diagnostics, gateway rows, attachment labels, fallback copy, and unavailable Talk controls reachable. (#90752, #91201)</li>
|
||||
<li>Memory: QMD search can use the new rerank toggle, and memory adapter status uses the resolved default model identity when checking plain status. (#61834)</li>
|
||||
<li>Docs/tooling: add Parallel search docs, refresh weather-skill guidance toward <code>web_fetch</code>, clarify legacy <code>openai-codex</code> auth, document release/test helper scripts, and tighten changed-test routing docs for CI/debugging work. (#90028, #90250) Thanks @fuller-stack-dev.</li>
|
||||
<li>Release/process: switch release trains to <code>YYYY.M.PATCH</code> monthly patch numbering, keep pre-transition tags compatible, and pin the June 2026 floor at <code>2026.6.5</code>.</li>
|
||||
<li>Release/process: defer the session-metadata SQLite migration from the <code>2026.6.5</code> beta train so this release keeps the existing JSON-backed session metadata path while the migration risk is worked on <code>main</code>.</li>
|
||||
<li>Release metadata: align OpenClaw, publishable plugin manifests, generated shrinkwraps, app version metadata, iOS release notes, Matrix plugin changelog, and generated release baselines with the <code>2026.6.5</code> release train.</li>
|
||||
<li>Platform maintenance: refresh Android, Swift/macOS, Docker, CodeQL, Buildx, Docker build/push, and Codex Action dependencies for this release train. (#74980, #81757, #86481, #86483, #90601)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until <code>message_start</code>, 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.</li>
|
||||
<li>Agents/Codex/tools: MCP lease release no longer refreshes <code>lastUsedAt</code>, prompt-cache tool names are guarded, lean local tool catalogs stay compact, unreadable dynamic tools are quarantined, orphan tool errors still surface, native subagent completion results survive app-server monitoring, and background-session name derivation avoids regex backtracking risk. (#91124, #90612, #90022, #91235, #91233)</li>
|
||||
<li>Provider/model resolution: preserve Google Vertex ADC auth markers in generated catalogs, re-probe a single-provider primary after cooldown, share Codex model visibility, fail closed for unknown model auth, preserve Codex alias availability, keep unresolved profile refs unknown, and avoid resolving auth while listing models. (#90506, #90609, #90717, #90702) Thanks @849261680.</li>
|
||||
<li>Provider/model resolution: live provider model catalogs keep helper coverage, Ollama catalog metadata is preserved, Google provider prefixes are stripped from Gemini paths, Foundry Responses reasoning replay ids survive, MiniMax M3 thinking stays enabled, Vertex multi-region calls use the right regional host, and OpenRouter streamed generation cost is reconciled. (#91125)</li>
|
||||
<li>Gateway/macOS/mobile: avoid duplicate Gateway probe warnings by identity, rate-limit node pairing requests while preserving paired-node reconnects, keep macOS node mode on a healthy direct Gateway session, keep iOS diagnostics and gateway rows reachable, and avoid Linux ARM Gradle resource tasks during Android builds. (#85791, #90147, #90668, #90815) Thanks @giodl73-repo and @vrurg.</li>
|
||||
<li>Gateway/security/config: owner-only HTTP tools are gated, sandbox skills remain readable in writable sandboxes, legacy agent registry and Codex model metadata migrate safely, and stalled MCP response bodies time out instead of tying up Gateway workers. (#90261)</li>
|
||||
<li>Gateway/config: <code>config.patch</code> now preserves explicit array replacement semantics for arrays without merge keys, so replacement patches do not accidentally merge stale entries. (#91551)</li>
|
||||
<li>SDK: event pump failures now surface to clients instead of being swallowed behind a quiet iterator shutdown.</li>
|
||||
<li>Agents/transcripts: inline image payload redaction now catches data URLs and repaired transcript images before they can leak raw image bytes into stored or exported transcripts. (#91529)</li>
|
||||
<li>Plugins/Gateway: legacy flat Control UI descriptors from shipped JavaScript plugins now normalize <code>name</code> and missing surface fields into session descriptors, restoring Kitchen Sink RPC descriptor proof for package-backed plugin validation.</li>
|
||||
<li>TUI/chat/Workboard/auto-reply: optimistic user messages stay stable across stale history reloads, runId reassignment, and abort windows instead of disappearing, jumping, or lingering as ghost rows; Workboard stale lifecycle bulk updates no longer overwrite newer status/provenance; message-tool sends now count as delivery. (#86205, #89600, #88592, #90123) Thanks @RomneyDa.</li>
|
||||
<li>Cron/update/service env: doctor config preflight now migrates legacy cron JSON stores into SQLite before runtime reads, isolated agent turn payload messages preserve timeout context, service env planning skips unresolved placeholders that would mask state-dir <code>.env</code> values, and session transcript rewrites keep registry markers/discriminants consistent. (#90072, #90208, #91230, #90277, #90488) Thanks @MonkeyLeeT and @sallyom.</li>
|
||||
<li>State/storage: Matrix sync and crypto sidecars, memory-wiki import/source-sync state, sandbox registry state, ACPX process state, device-pair notify state, Zalo hosted media, and plugin SDK dedupe state now use SQLite-owned storage instead of ad hoc runtime files. (#91100, #91108, #91056)</li>
|
||||
<li>Security/config/tooling: guard MCP HTTP redirects, protect global agent config defaults, and keep release/test/tooling proof failures bounded and explicit. (#89732, #90145)</li>
|
||||
<li>Channels: WhatsApp restarts when per-account config changes, bounds background startup waits, closes failed sockets, and preserves reconnect behavior; Mattermost slash commands keep their state on <code>globalThis</code> and default replies stay inside existing Mattermost threads instead of starting new ones; Feishu streaming cards preserve full merged content; iMessage private-API failures and send timeouts explain themselves while split-send coalescing honors balloon metadata; voice-call tracks Twilio streams after connect; ClickClack reply tools respect <code>toolsAllow</code>; Discord runtime adapters stay resolvable; and outbound delivery retries survive budget deferrals. (#87951, #87965, #90486, #68113, #90534, #90181, #90607, #89500, #91041, #90858, #91119, #91241) Thanks @MukundaKatta, @mcaxtr, @infoanton, @mushuiyu886, @sahibzada-allahyar, and @jacobtomlinson.</li>
|
||||
<li>Feishu: retry transient send rate-limit errors (HTTP 429, per-chat code 230020, tenant-level code 11232) with linear backoff, including SDK responses that fulfill with rate-limit bodies instead of throwing, and route streaming-card sends through the retry wrapper. (#89659) Thanks @ladygege.</li>
|
||||
<li>WhatsApp: captured replies after restart now route through the successor controller instead of the stale pre-restart controller. (#85823)</li>
|
||||
<li>Release/CI/E2E: main CI guard drift, PR merge diff scoping, live Docker credential staging, base-image qualification, installer Docker classification, Playwright dependency install recovery, API-key auth for Codex live Docker lanes, Parallels option terminators, and JSON-mode progress handling are tighter so release proof fails cleaner. (#90532, #90287, #90058) Thanks @RomneyDa, @hxy91819, and @mrunalp.</li>
|
||||
<li>Release/CI/E2E: installed-package root dist verification now allows the current package's JavaScript file count while keeping dependency, per-file-size, and scan-bound checks active.</li>
|
||||
<li>Release/CI/E2E: Chutes OAuth model-discovery proof now accepts standard <code>Headers</code> requests, and QR package install smoke caps Docker CPU requests to the hosted runner capacity so beta validation fails on real package regressions.</li>
|
||||
<li>Release/CI/E2E: Docker E2E and live Docker harness runs now apply default memory, CPU, and process ceilings while preserving explicit per-lane overrides.</li>
|
||||
<li>Release/CI/E2E: Docker E2E CPU limits now cap to the runner capacity, keeping package Telegram acceptance on hosted 8-vCPU runners focused on package regressions instead of impossible Docker resource requests.</li>
|
||||
<li>Release/CI/E2E: task maintenance release checks now reset pinned config around isolated temp state dirs, keeping normal CI focused on the active session-store fixture instead of stale process snapshots.</li>
|
||||
<li>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.</li>
|
||||
<li>Release/CI/E2E: Codex npm plugin live assertions now cap transcript discovery and diagnostic log reads so failure proof stays bounded.</li>
|
||||
<li>Release/CI/E2E: browser snapshot, release-scenario, release-user-journey, Telegram desktop/RTT/package, web-search, Parallels update, plugin update, doctor switch, and upgrade-survivor diagnostics now stream or bound log/artifact reads so failed proof stays inspectable without unbounded output.</li>
|
||||
<li>Release/CI/E2E: Parallels smoke validation now runs without requiring <code>pnpm</code> on the host, supports already-started Windows/Linux guests without snapshots, reports empty snapshot metadata clearly, and finds portable user-local Node on Windows.</li>
|
||||
<li>Release/CI/E2E: ClawHub publish jobs prepare dependencies after checking out the target ref, and Docker store seed package discovery now targets the intended production packages. (#91547)</li>
|
||||
<li>Release/CI/E2E: QA Lab capability-flip release validation now marks intentional <code>tools.deny</code> restores as array replacements, so beta validation fails only on real capability regressions.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>Tests/state isolation: QA Lab runtime parity now treats matching controlled tool errors as equivalent and falls back to transcript tool results when mock debug rows miss async image-generation starts.</li>
|
||||
<li>Tests/state isolation: QA suites now fail closed on skipped summaries, missing runtime tool proof, planned-only rows, loose release limits, missing live/provider artifacts, failed agent reply markers, and package Telegram summary failures.</li>
|
||||
<li>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)</li>
|
||||
<li>Sessions: the beta SQLite downgrade rescue now skips extra pre-reads for active non-empty JSON session stores, preserving cache race detection while still restoring missing or empty beta session files.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
@@ -273,5 +193,52 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.28/OpenClaw-2026.5.28.zip" length="54750142" type="application/octet-stream" sparkle:edSignature="U4O55uMdPU+OqSx9QR1ApUJ8wg65wxTydzD7iyCn1GHtm1MBK9noEeiA/yoUKkqb/bx0hzi1gNhn+ye19RXnCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.5.27</title>
|
||||
<pubDate>Thu, 28 May 2026 12:12:19 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026052790</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.5.27</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.5.27</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Stronger security and content boundaries: group prompt text is kept out of the system prompt, repeated-dot hostnames are normalized, side-effecting command wrappers and unsafe Node runtime env overrides are blocked, no-auth Tailscale exposure is rejected, and node/device-role approvals now require admin authority. (#87144, #87305, #87292, #87308, #87146) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>More reliable Codex app-server runs: Codex runtime models resolve first, workspace memory is routed through tools, shared app-server clients survive startup and spawned-helper failures, native hook relay generations survive restarts and rotate on fresh fallbacks, and false runtime live switches are avoided. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Faster Gateway and reply paths: session reads, plugin metadata fingerprints, auth env snapshots, auto-enabled plugin config, tool-search catalogs, and stable metadata caches do less hot-path rediscovery while visible replies no longer inherit hidden cleanup timeouts. (#86439, #87044) Thanks @keshavbotagent.</li>
|
||||
<li>Better provider and model coverage: OpenAI-compatible embedding providers are core, DeepInfra catalog browsing loads the full credential-aware model set, Pixverse adds video generation and API region selection, VLLM thinking params are wired, Claude CLI OAuth overlays load for PI auth profiles, and bare direct Anthropic model ids work. (#85269, #84549, #87167) Thanks @dutifulbob, @ats3v, and @joshavant.</li>
|
||||
<li>Channel delivery is steadier: Telegram <code>sendMessage</code> actions use durable outbound delivery, iMessage suppresses duplicate native exec approval prompts and sends, Slack keeps delivered final replies during late cleanup, Matrix mention previews/finals are stricter, QQBot fallback approval buttons honor slash-command auth, Discord guild requester checks are tighter, recovered Discord tool-warning artifacts stay out of successful replies, and Google Chat stops thread sends in DMs. (#87261, #87154) Thanks @mbelinky and @eleqtrizit.</li>
|
||||
<li>Release, package, and CI proof paths are harder to wedge: npm/package inventory honors dist exclusions, shrinkwrap override pins merge correctly, Docker runtime workspace templates are packaged and smoked, release postpublish checks are stricter, beta smoke rejects empty runs, and E2E log/probe waits are bounded.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Memory: add a core OpenAI-compatible embedding provider for local and hosted OpenAI-style endpoints, with config, doctor, and docs support. (#85269) Thanks @dutifulbob.</li>
|
||||
<li>Plugin SDK: mark memory-specific embedding provider registration as deprecated compatibility and surface non-bundled usage in plugin compatibility diagnostics. (#85072) Thanks @mbelinky.</li>
|
||||
<li>Providers: add the Pixverse video generation provider, API region selection, docs, and external plugin packaging support.</li>
|
||||
<li>DeepInfra: load the full model catalog when users browse models during onboarding, preserve configured API-key catalogs, refresh media/video defaults, and keep pricing/default model metadata aligned. (#84549) Thanks @ats3v.</li>
|
||||
<li>Plugin SDK: expose plugin approval action metadata and stop exporting Vitest test helpers from the public SDK surface. (#87120) Thanks @RomneyDa.</li>
|
||||
<li>Channel SDK: move channel message compatibility into core, remove old channel turn runtime aliases, and preserve runtime catalog markdown metadata for plugins.</li>
|
||||
<li>ClawHub: add plugin display metadata so catalog/package listings use cleaner names. (#87354) Thanks @thewilloftheshadow.</li>
|
||||
<li>Agents: split the heartbeat runtime template out of docs assets and add compatibility repair for legacy heartbeat template content. (#85416) Thanks @hxy91819.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security/content boundaries: route untrusted group prompt metadata outside system prompts, normalize repeated trailing hostname dots, block side-effecting command wrappers, reject unsafe Node runtime env overrides, reject no-auth Tailscale exposure, block untrusted Microsoft Teams service URLs, enforce <code>/allowlist configWrites</code> origin policy, gate QQBot fallback approval buttons, and require admin for node/device-role approvals. (#87144, #87305, #87292, #87308, #87146, #87154, #87334) Thanks @eleqtrizit and @pgondhi987.</li>
|
||||
<li>Codex: resolve Codex runtime models before generic routing, route workspace memory through tools, preserve shared app-server clients after startup and spawned-helper failures, preserve native hook relay generations across restarts and fresh fallbacks, keep raw reasoning/source-reply guards intact, report quarantined dynamic tools, keep the attempt watchdog armed for queued terminal turns, and route Codex OAuth compaction through OpenAI-Codex. (#87383, #87403, #87375, #72574, #87428) Thanks @yetval.</li>
|
||||
<li>Agents/runtime: avoid session event queue self-waits, bound compaction wake and steering retries, preserve grace for pending error diagnostics, avoid false Codex runtime live switches, avoid stale restart continuation reuse, preserve session fallback errors, suppress duplicate Claude CLI skill prompts, keep runtime context before active user turns, strip stale Anthropic thinking, quarantine unsupported tool schemas, recover completed write timeouts safely, release retained session write locks on timeout abort, and validate forced plugin harness support before pinning. (#86123, #55424, #86855, #74341, #87278) Thanks @luoyanglang, @cathrynlavery, and @openperf.</li>
|
||||
<li>Reply/session delivery: keep visible turn admission unbounded, keep visible fallback delivery on latest targets, preserve bridge hook context, classify direct fallback targets by channel grammar, report approval resolutions in bridge mode, and avoid stale source-reply artifacts. (#87044) Thanks @keshavbotagent.</li>
|
||||
<li>Channels: make Telegram <code>sendMessage</code> action replies durable and preserve SecretRef prompt config, suppress duplicate iMessage native exec approval prompts and sends, keep iMessage approval polling alive after denied reactions, keep Slack delivered final replies during late cleanup, keep Matrix mention previews/finals mention-inert and normally delivered, ignore filename-embedded Matrix IDs, suppress recovered Discord tool-warning artifacts from successful replies, suppress Google Chat thread sends in DMs, and harden Discord guild requester checks. (#87261, #87452) Thanks @mbelinky.</li>
|
||||
<li>Memory: salvage QMD search JSON after nonzero exits and keep workspace memory routing through the Codex tool path where possible. (#87225, #87383, #87403) Thanks @osolmaz.</li>
|
||||
<li>Providers/models: forward cached token usage in OpenAI-compatible chat completions, load Claude CLI OAuth overlays for PI auth profiles, send bare direct Anthropic model ids, wire configured VLLM thinking params, honor OpenAI-compatible cache retention, normalize OpenAI Responses replay tool ids, resolve OpenAI <code>gpt-5.5</code> without a cached catalog, preserve <code>retry-after</code> fallback handling, bound GitHub Copilot auth requests, and load DeepInfra custom/live catalogs consistently. (#82062, #87167, #84549) Thanks @caz0075, @joshavant, and @ats3v.</li>
|
||||
<li>Gateway/performance: borrow read-only session metadata and active session working stores, cache current/stable plugin metadata fingerprints, cache auto-enabled plugin config, slim metadata identity caches, trust current metadata lifecycle caches, stabilize isolated cron prompt-cache affinity, persist model auth profile suffixes, drain probe client closes, expire browser tokens after auth rotation, and keep default status fast paths bounded. Thanks @ferminquant.</li>
|
||||
<li>CLI/help/config: reject loose or malformed numeric options for gateway timeouts, model limits, directory limits, message options, webhooks, and partial values; respect subcommand version options; route generated/root/plugin help targets correctly; keep skills JSON output flushing naturally; and keep plugin descriptor loading quiet in root help. (#87398) Thanks @Patrick-Erichsen.</li>
|
||||
<li>Plugin state/tool search: evict the current namespace when plugin rows hit caps, reuse unchanged tool-search catalogs, align the release catalog reuse wrapper, and keep fallback tool warnings mention-inert.</li>
|
||||
<li>Install/package/release: match npm globstar exclusions, honor dist package exclusions in inventory, omit unpacked test helpers, skip Homebrew until macOS packages need it, package Docker runtime workspace templates, smoke Docker runtime templates during full validation, merge nested shrinkwrap override pins, preserve forked shrinkwrap pins, pin aged <code>lru-cache</code>, harden postpublish verification, accept main full-validation proof, and reject empty beta smoke runs.</li>
|
||||
<li>E2E/QA/Crabbox: bound Telegram, Open WebUI, ClawHub, Matrix, Tool Search, MCP, gateway network, bundled runtime, kitchen-sink, codex media, config reload, and agent-turn assertion waits; prefer Azure for Windows targets; reinitialize invalid changed-gate git dirs; full-sync sparse container runs; and fail empty explicit test requests. (#87186)</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.5.27/OpenClaw-2026.5.27.zip" length="54488811" type="application/octet-stream" sparkle:edSignature="c5w2T1UO6vpPs70hyYH93cIyWEOd5sl5z2NkhU53E+XQBSd+jAr+xd0qf3KzWbeX2mfXYMQmnx+VMls3L22EDg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -41,7 +41,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app"
|
||||
compileSdk = 37
|
||||
compileSdk = 36
|
||||
|
||||
// Release signing is local-only; keep the keystore path and passwords out of the repo.
|
||||
signingConfigs {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
@@ -51,7 +50,7 @@
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="connectedDevice|microphone" />
|
||||
android:foregroundServiceType="dataSync|microphone" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
/** User-selectable app theme mode for Android appearance settings. */
|
||||
enum class AppearanceThemeMode(
|
||||
val rawValue: String,
|
||||
val displayLabel: String,
|
||||
) {
|
||||
System(rawValue = "system", displayLabel = "System"),
|
||||
Dark(rawValue = "dark", displayLabel = "Dark"),
|
||||
Light(rawValue = "light", displayLabel = "Light"),
|
||||
;
|
||||
|
||||
fun isDark(systemDark: Boolean): Boolean =
|
||||
when (this) {
|
||||
System -> systemDark
|
||||
Dark -> true
|
||||
Light -> false
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromRawValue(value: String?): AppearanceThemeMode = entries.firstOrNull { it.rawValue == value?.trim()?.lowercase() } ?: Dark
|
||||
|
||||
fun fromDisplayLabel(label: String): AppearanceThemeMode = entries.firstOrNull { it.displayLabel.equals(label.trim(), ignoreCase = true) } ?: Dark
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -65,16 +64,8 @@ class MainActivity : ComponentActivity() {
|
||||
activeViewModel = readyViewModel
|
||||
}
|
||||
|
||||
val currentViewModel = activeViewModel
|
||||
if (currentViewModel == null) {
|
||||
OpenClawTheme {
|
||||
StartupSurface()
|
||||
}
|
||||
} else {
|
||||
val appearanceThemeMode by currentViewModel.appearanceThemeMode.collectAsState()
|
||||
OpenClawTheme(themeMode = appearanceThemeMode) {
|
||||
RootScreen(viewModel = currentViewModel)
|
||||
}
|
||||
OpenClawTheme {
|
||||
activeViewModel?.let { RootScreen(viewModel = it) } ?: StartupSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,6 @@ class MainViewModel(
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val installedAppsSharingEnabled: StateFlow<Boolean> = prefs.installedAppsSharingEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = prefs.appearanceThemeMode
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
|
||||
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
|
||||
|
||||
@@ -441,10 +440,6 @@ class MainViewModel(
|
||||
ensureRuntime().setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
|
||||
prefs.setAppearanceThemeMode(mode)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
ensureRuntime().refreshGatewayConnection()
|
||||
|
||||
@@ -23,6 +23,7 @@ import kotlinx.coroutines.launch
|
||||
class NodeForegroundService : Service() {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var notificationJob: Job? = null
|
||||
private var didStartForeground = false
|
||||
private var voiceCaptureMode = VoiceCaptureMode.Off
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -182,7 +183,13 @@ class NodeForegroundService : Service() {
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification) {
|
||||
val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode)
|
||||
if (didStartForeground) {
|
||||
// Re-issue startForeground when Talk mode toggles so Android sees the microphone service type.
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
|
||||
return
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
|
||||
didStartForeground = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -193,16 +200,19 @@ class NodeForegroundService : Service() {
|
||||
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
|
||||
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
|
||||
|
||||
/** Starts the persistent node foreground service from UI lifecycle code. */
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
}
|
||||
|
||||
/** Requests disconnect through the service action path so notification actions and UI share behavior. */
|
||||
fun stop(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
/** Updates Android's foreground-service type before voice capture mode changes require microphone access. */
|
||||
fun setVoiceCaptureMode(
|
||||
context: Context,
|
||||
mode: VoiceCaptureMode,
|
||||
@@ -221,8 +231,11 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground-service type mask required by Android for the current voice capture mode.
|
||||
*/
|
||||
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
|
||||
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
|
||||
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
return if (mode == VoiceCaptureMode.TalkMode) {
|
||||
base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
@@ -230,6 +243,9 @@ internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact notification suffix for voice state; kept pure for service-notification tests.
|
||||
*/
|
||||
internal fun voiceNotificationSuffix(
|
||||
mode: VoiceCaptureMode,
|
||||
manualMicEnabled: Boolean,
|
||||
|
||||
@@ -42,7 +42,6 @@ class SecurePrefs(
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
private const val installedAppsSharingEnabledKey = "device.apps.sharing.enabled"
|
||||
private const val voiceMicEnabledKey = "voice.micEnabled"
|
||||
private const val appearanceThemeModeKey = "appearance.themeMode"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
@@ -182,10 +181,6 @@ class SecurePrefs(
|
||||
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
|
||||
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
|
||||
|
||||
private val _appearanceThemeMode =
|
||||
MutableStateFlow(AppearanceThemeMode.fromRawValue(plainPrefs.getString(appearanceThemeModeKey, null)))
|
||||
val appearanceThemeMode: StateFlow<AppearanceThemeMode> = _appearanceThemeMode
|
||||
|
||||
fun setLastDiscoveredStableId(value: String) {
|
||||
val trimmed = value.trim()
|
||||
plainPrefs.edit { putString("gateway.lastDiscoveredStableID", trimmed) }
|
||||
@@ -530,11 +525,6 @@ class SecurePrefs(
|
||||
_speakerEnabled.value = value
|
||||
}
|
||||
|
||||
fun setAppearanceThemeMode(mode: AppearanceThemeMode) {
|
||||
plainPrefs.edit { putString(appearanceThemeModeKey, mode.rawValue) }
|
||||
_appearanceThemeMode.value = mode
|
||||
}
|
||||
|
||||
private fun loadNotificationForwardingPackages(): Set<String> {
|
||||
val raw = plainPrefs.getString(notificationsForwardingPackagesKey, null)?.trim()
|
||||
if (raw.isNullOrEmpty()) {
|
||||
|
||||
@@ -49,19 +49,6 @@ import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
private fun createDnsResolver(context: Context): DnsResolver =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
createContextDnsResolver(context)
|
||||
} else {
|
||||
createLegacyDnsResolver()
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.CINNAMON_BUN)
|
||||
private fun createContextDnsResolver(context: Context): DnsResolver = DnsResolver(context, null)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun createLegacyDnsResolver(): DnsResolver = DnsResolver.getInstance()
|
||||
|
||||
/**
|
||||
* Watches local DNS-SD and optional wide-area DNS-SD for reachable OpenClaw gateways.
|
||||
*/
|
||||
@@ -71,7 +58,7 @@ class GatewayDiscovery(
|
||||
) {
|
||||
private val nsd = context.getSystemService(NsdManager::class.java)
|
||||
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
|
||||
private val dns = createDnsResolver(context)
|
||||
private val dns = DnsResolver.getInstance()
|
||||
private val serviceType = "_openclaw-gw._tcp."
|
||||
private val wideAreaDomain = System.getenv("OPENCLAW_WIDE_AREA_DOMAIN")
|
||||
private val logTag = "OpenClaw/GatewayDiscovery"
|
||||
|
||||
@@ -41,27 +41,27 @@ internal data class MobileColors(
|
||||
|
||||
internal fun lightMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFFFAFBFC),
|
||||
surfaceStrong = Color(0xFFEFF3F8),
|
||||
surface = Color(0xFFF6F7FA),
|
||||
surfaceStrong = Color(0xFFECEEF3),
|
||||
cardSurface = Color(0xFFFFFFFF),
|
||||
border = Color(0xFFDDE3EC),
|
||||
borderStrong = Color(0xFFC7D0DC),
|
||||
text = Color(0xFF16181D),
|
||||
textSecondary = Color(0xFF505B6A),
|
||||
textTertiary = Color(0xFF8E98A7),
|
||||
accent = Color(0xFF1B5ACB),
|
||||
accentSoft = Color(0xFFEAF2FF),
|
||||
accentBorderStrong = Color(0xFF174CA9),
|
||||
success = Color(0xFF287F52),
|
||||
successSoft = Color(0xFFEAF7F0),
|
||||
warning = Color(0xFFAF7418),
|
||||
warningSoft = Color(0xFFFFF4DF),
|
||||
danger = Color(0xFFC94343),
|
||||
dangerSoft = Color(0xFFFFECEC),
|
||||
codeBg = Color(0xFFEFF3F8),
|
||||
codeText = Color(0xFF172033),
|
||||
codeBorder = Color(0xFFD7DDE7),
|
||||
codeAccent = Color(0xFF287F52),
|
||||
border = Color(0xFFE5E7EC),
|
||||
borderStrong = Color(0xFFD6DAE2),
|
||||
text = Color(0xFF17181C),
|
||||
textSecondary = Color(0xFF5D6472),
|
||||
textTertiary = Color(0xFF99A0AE),
|
||||
accent = Color(0xFF1D5DD8),
|
||||
accentSoft = Color(0xFFECF3FF),
|
||||
accentBorderStrong = Color(0xFF184DAF),
|
||||
success = Color(0xFF2F8C5A),
|
||||
successSoft = Color(0xFFEEF9F3),
|
||||
warning = Color(0xFFC8841A),
|
||||
warningSoft = Color(0xFFFFF8EC),
|
||||
danger = Color(0xFFD04B4B),
|
||||
dangerSoft = Color(0xFFFFF2F2),
|
||||
codeBg = Color(0xFF15171B),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFFCFEBD8),
|
||||
chipBorderConnecting = Color(0xFFD5E2FA),
|
||||
chipBorderWarning = Color(0xFFEED8B8),
|
||||
|
||||
@@ -34,7 +34,6 @@ import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -131,9 +130,7 @@ fun OnboardingFlow(
|
||||
viewModel: MainViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
val onboardingDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
ClawDesignTheme(dark = onboardingDark) {
|
||||
ClawDesignTheme {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
@@ -162,8 +159,6 @@ fun OnboardingFlow(
|
||||
var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) }
|
||||
var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) }
|
||||
|
||||
OpenClawSystemBarAppearance(lightAppearance = !onboardingDark && step != OnboardingStep.Welcome)
|
||||
|
||||
val qrScannerOptions =
|
||||
remember {
|
||||
GmsBarcodeScannerOptions
|
||||
@@ -228,12 +223,10 @@ fun OnboardingFlow(
|
||||
|
||||
when (step) {
|
||||
OnboardingStep.Welcome ->
|
||||
ClawDesignTheme(dark = true) {
|
||||
WelcomeScreen(
|
||||
modifier = modifier,
|
||||
onConnect = { step = OnboardingStep.Gateway },
|
||||
)
|
||||
}
|
||||
WelcomeScreen(
|
||||
modifier = modifier,
|
||||
onConnect = { step = OnboardingStep.Gateway },
|
||||
)
|
||||
OnboardingStep.Gateway ->
|
||||
GatewaySetupScreen(
|
||||
modifier = modifier,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -9,51 +8,34 @@ import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
|
||||
*/
|
||||
@Composable
|
||||
fun OpenClawTheme(
|
||||
themeMode: AppearanceThemeMode = AppearanceThemeMode.Dark,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = themeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
|
||||
|
||||
OpenClawSystemBarAppearance(lightAppearance = !isDark)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalOpenClawDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as? Activity)?.window ?: return@SideEffect
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat
|
||||
.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightStatusBars = lightAppearance
|
||||
WindowCompat
|
||||
.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightNavigationBars = lightAppearance
|
||||
.isAppearanceLightStatusBars = !isDark
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,9 +44,9 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
|
||||
@Composable
|
||||
fun overlayContainerColor(): Color {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val isDark = LocalOpenClawDarkTheme.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
|
||||
// Light mode keeps overlays away from pure-white glare on the app canvas.
|
||||
// Light mode: background stays dark (canvas), so clamp overlays away from pure-white glare.
|
||||
return if (isDark) base else base.copy(alpha = 0.88f)
|
||||
}
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ private fun SessionRow(
|
||||
compact: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(onClick = onClick, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Surface(onClick = onClick, color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(vertical = 5.dp),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayCronJobSummary
|
||||
@@ -147,7 +146,7 @@ internal fun SettingsDetailScreen(
|
||||
SettingsRoute.Notifications -> NotificationSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.PhoneCapabilities -> PhoneCapabilitiesScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Gateway -> GatewaySettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Appearance -> AppearanceSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.Appearance -> AppearanceSettingsScreen(onBack = onBack)
|
||||
SettingsRoute.Health -> HealthLogsSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
SettingsRoute.About -> AboutSettingsScreen(viewModel = viewModel, onBack = onBack)
|
||||
}
|
||||
@@ -915,40 +914,22 @@ private fun GatewaySettingsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val themeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
|
||||
private fun AppearanceSettingsScreen(onBack: () -> Unit) {
|
||||
SettingsDetailFrame(title = "Appearance", subtitle = "A calm, high-contrast OpenClaw interface.", icon = Icons.Default.Palette, onBack = onBack) {
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Theme", appearanceThemeSummary(themeMode)),
|
||||
SettingsMetric("Theme", "Dark"),
|
||||
SettingsMetric("Contrast", "High"),
|
||||
SettingsMetric("Typography", "Readable"),
|
||||
),
|
||||
)
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "Theme", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
ClawSegmentedControl(
|
||||
options = appearanceThemeOptions(),
|
||||
selected = appearanceThemeSummary(themeMode),
|
||||
onSelect = { selected -> viewModel.setAppearanceThemeMode(appearanceThemeModeForLabel(selected)) },
|
||||
)
|
||||
}
|
||||
Text(text = "OpenClaw uses a fixed premium dark theme so it stays consistent across devices.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun appearanceThemeSummary(mode: AppearanceThemeMode): String = mode.displayLabel
|
||||
|
||||
internal fun appearanceThemeOptions(): List<String> = AppearanceThemeMode.entries.map { it.displayLabel }
|
||||
|
||||
internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label)
|
||||
|
||||
/** Converts raw gateway connection text into stable settings metric labels. */
|
||||
private fun gatewayStatusLabel(
|
||||
statusText: String,
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -115,10 +114,7 @@ fun ShellScreen(
|
||||
viewModel: MainViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
val shellDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
OpenClawSystemBarAppearance(lightAppearance = !shellDark)
|
||||
ClawDesignTheme(dark = shellDark) {
|
||||
ClawDesignTheme {
|
||||
var activeTab by rememberSaveable { mutableStateOf(Tab.Overview) }
|
||||
var settingsRoute by rememberSaveable { mutableStateOf(SettingsRoute.Home) }
|
||||
var returnToOverviewFromSettings by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -755,7 +751,7 @@ private fun RecentSessionRowContent(
|
||||
metadata: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
|
||||
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
@@ -853,7 +849,6 @@ private fun SettingsShellScreen(
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val dreamingSummary by viewModel.dreamingSummary.collectAsState()
|
||||
val appearanceThemeMode by viewModel.appearanceThemeMode.collectAsState()
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -915,7 +910,7 @@ private fun SettingsShellScreen(
|
||||
SettingsRow("Notifications", if (notificationForwardingEnabled) "Smart delivery" else "Off", Icons.Default.Notifications, route = SettingsRoute.Notifications),
|
||||
SettingsRow("Phone Capabilities", if (cameraEnabled) "Camera enabled" else "Locked", Icons.Default.Lock, status = !cameraEnabled, route = SettingsRoute.PhoneCapabilities),
|
||||
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
|
||||
SettingsRow("Appearance", appearanceThemeSummary(appearanceThemeMode), Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow("Appearance", "Dark", Icons.Default.Palette, route = SettingsRoute.Appearance),
|
||||
SettingsRow("Health", "Diagnostics", Icons.Default.Settings, status = isConnected, route = SettingsRoute.Health),
|
||||
SettingsRow("About", "Version and update", Icons.Default.Storage, route = SettingsRoute.About),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import ai.openclaw.app.ui.LocalMobileColors
|
||||
import ai.openclaw.app.ui.darkMobileColors
|
||||
import ai.openclaw.app.ui.lightMobileColors
|
||||
import ai.openclaw.app.ui.mobileFontFamily
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -113,22 +110,22 @@ private val ClawDarkColors =
|
||||
|
||||
private val ClawLightColors =
|
||||
ClawColors(
|
||||
canvas = Color(0xFFFAFBFC),
|
||||
surface = Color(0xFFFFFEFB),
|
||||
canvas = Color(0xFFF7F7F7),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
surfaceRaised = Color(0xFFFFFFFF),
|
||||
surfacePressed = Color(0xFFE9EDF3),
|
||||
border = Color(0xFFDDE3EC),
|
||||
borderStrong = Color(0xFFC7D0DC),
|
||||
text = Color(0xFF111318),
|
||||
textMuted = Color(0xFF505865),
|
||||
textSubtle = Color(0xFF8993A2),
|
||||
primary = Color(0xFF111827),
|
||||
surfacePressed = Color(0xFFEDEDED),
|
||||
border = Color(0xFFE0E0E0),
|
||||
borderStrong = Color(0xFFBDBDBD),
|
||||
text = Color(0xFF070707),
|
||||
textMuted = Color(0xFF595959),
|
||||
textSubtle = Color(0xFF8A8A8A),
|
||||
primary = Color(0xFF050505),
|
||||
primaryText = Color(0xFFFFFFFF),
|
||||
success = Color(0xFF217747),
|
||||
successSoft = Color(0xFFE9F7EF),
|
||||
warning = Color(0xFFA56F17),
|
||||
warningSoft = Color(0xFFFFF3DC),
|
||||
danger = Color(0xFFB82929),
|
||||
success = Color(0xFF157A3E),
|
||||
successSoft = Color(0xFFEAF8EF),
|
||||
warning = Color(0xFF9A6A12),
|
||||
warningSoft = Color(0xFFFFF5DD),
|
||||
danger = Color(0xFFB42323),
|
||||
dangerSoft = Color(0xFFFFE9E9),
|
||||
)
|
||||
|
||||
@@ -171,12 +168,10 @@ internal fun ClawDesignTheme(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = if (dark) ClawDarkColors else ClawLightColors
|
||||
val mobileColors = if (dark) darkMobileColors() else lightMobileColors()
|
||||
val typography = clawTypography(mobileFontFamily)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalClawColors provides colors,
|
||||
LocalMobileColors provides mobileColors,
|
||||
LocalClawSpacing provides ClawSpacing(),
|
||||
LocalClawRadii provides ClawRadii(),
|
||||
LocalClawTypography provides typography,
|
||||
|
||||
@@ -34,15 +34,15 @@ class NodeForegroundServiceTest {
|
||||
@Test
|
||||
fun foregroundServiceTypesForVoiceMode_addsMicrophoneOnlyForTalkMode() {
|
||||
assertEquals(
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.Off),
|
||||
)
|
||||
assertEquals(
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.ManualMic),
|
||||
)
|
||||
assertEquals(
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.TalkMode),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,31 +77,6 @@ class SecurePrefsTest {
|
||||
assertTrue(plainPrefs.getBoolean("device.apps.sharing.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeMode_defaultsDarkForExistingInstalls() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
assertEquals(AppearanceThemeMode.Dark, prefs.appearanceThemeMode.value)
|
||||
assertFalse(plainPrefs.contains("appearance.themeMode"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setAppearanceThemeMode_persistsSelectedMode() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
prefs.setAppearanceThemeMode(AppearanceThemeMode.Light)
|
||||
|
||||
assertEquals(AppearanceThemeMode.Light, prefs.appearanceThemeMode.value)
|
||||
assertEquals("light", plainPrefs.getString("appearance.themeMode", null))
|
||||
assertEquals(AppearanceThemeMode.Light, SecurePrefs(context).appearanceThemeMode.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
@@ -18,28 +17,6 @@ class ShellScreenLogicTest {
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeModeDefaultsToDarkForExistingInstalls() {
|
||||
assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue(null))
|
||||
assertEquals(AppearanceThemeMode.Dark, AppearanceThemeMode.fromRawValue("unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeLabelsRoundTripFromSettingsOptions() {
|
||||
assertEquals(listOf("System", "Dark", "Light"), appearanceThemeOptions())
|
||||
assertEquals(AppearanceThemeMode.System, appearanceThemeModeForLabel("System"))
|
||||
assertEquals(AppearanceThemeMode.Dark, appearanceThemeModeForLabel("Dark"))
|
||||
assertEquals(AppearanceThemeMode.Light, appearanceThemeModeForLabel("Light"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appearanceThemeModeResolvesAgainstSystemPreference() {
|
||||
assertFalse(AppearanceThemeMode.System.isDark(systemDark = false))
|
||||
assertTrue(AppearanceThemeMode.System.isDark(systemDark = true))
|
||||
assertTrue(AppearanceThemeMode.Dark.isDark(systemDark = false))
|
||||
assertFalse(AppearanceThemeMode.Light.isDark(systemDark = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
|
||||
val rows =
|
||||
|
||||
@@ -5,7 +5,7 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "ai.openclaw.app.benchmark"
|
||||
compileSdk = 37
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 31
|
||||
|
||||
@@ -4,7 +4,7 @@ androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.05.01"
|
||||
androidx-core = "1.19.0"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-security = "1.1.0"
|
||||
@@ -19,7 +19,7 @@ junit = "4.13.2"
|
||||
junit-vintage = "6.1.0"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.4.0"
|
||||
kotlin = "2.3.21"
|
||||
material = "1.14.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
|
||||
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
|
||||
|
||||
@@ -329,13 +329,6 @@ struct AgentConfigLite: Decodable {
|
||||
struct ConfigPatchParams: Encodable {
|
||||
let raw: String
|
||||
let baseHash: String
|
||||
let replacePaths: [String]?
|
||||
|
||||
init(raw: String, baseHash: String, replacePaths: [String]? = nil) {
|
||||
self.raw = raw
|
||||
self.baseHash = baseHash
|
||||
self.replacePaths = replacePaths
|
||||
}
|
||||
}
|
||||
|
||||
enum SkillMutationError: LocalizedError {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -621,10 +621,7 @@ extension AgentProTab {
|
||||
}
|
||||
|
||||
let raw = try Self.agentSkillsPatchRaw(agentId: self.activeAgentID, skills: skills)
|
||||
let params = ConfigPatchParams(
|
||||
raw: raw,
|
||||
baseHash: baseHash,
|
||||
replacePaths: ["agents.list[].skills"])
|
||||
let params = ConfigPatchParams(raw: raw, baseHash: baseHash)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
throw SkillMutationError.invalidPatchPayload
|
||||
|
||||
@@ -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
|
||||
@@ -57,39 +57,9 @@ struct SettingsProTab: View {
|
||||
@State var notificationActionText = "Request Access"
|
||||
@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 +77,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 +118,61 @@ 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.$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 },
|
||||
@@ -625,21 +610,6 @@ extension SettingsProTab {
|
||||
return self.appModel.talkMode.gatewayTalkApiKeyConfigured ? "Configured" : "Not configured"
|
||||
}
|
||||
|
||||
var gatewayTalkActiveVoiceDetail: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
var gatewayTalkLastIssueDetail: String? {
|
||||
let detail = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return detail.isEmpty ? nil : detail
|
||||
}
|
||||
|
||||
func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
|
||||
@@ -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 {
|
||||
@@ -830,44 +792,26 @@ extension SettingsProTab {
|
||||
}
|
||||
|
||||
var talkVoiceSettingsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.gatewayConnected,
|
||||
let issue = self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||||
{
|
||||
TalkRuntimeIssueBanner(
|
||||
issue: issue,
|
||||
onOpenSettings: nil,
|
||||
onShowDetails: {
|
||||
self.showTalkIssueDetails = true
|
||||
})
|
||||
}
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
ProCard(radius: SettingsLayout.cardRadius) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Picker("Provider", selection: self.talkProviderSelectionBinding) {
|
||||
ForEach(TalkModeProviderSelection.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider()
|
||||
self.detailRow("Active Voice", value: self.gatewayTalkActiveVoiceDetail)
|
||||
if let issue = self.gatewayTalkLastIssueDetail {
|
||||
Divider()
|
||||
self.detailRow("Last Voice Issue", value: issue)
|
||||
}
|
||||
Divider()
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
Divider()
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
if self.shouldShowRealtimeVoicePicker {
|
||||
Picker("Realtime Voice", selection: self.talkRealtimeVoiceSelectionBinding) {
|
||||
Text("Gateway Default").tag("")
|
||||
ForEach(TalkModeRealtimeVoiceSelection.voices, id: \.self) { voice in
|
||||
Text(TalkModeRealtimeVoiceSelection.label(for: voice)).tag(voice)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.detailRow("Voice Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider()
|
||||
self.detailRow("Transport", value: self.appModel.talkMode.gatewayTalkTransportLabel)
|
||||
Divider()
|
||||
self.detailRow("API Key", value: self.talkApiKeyStatus)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,18 +8,8 @@ struct TalkProTab: View {
|
||||
TalkDefaults.speakerphoneEnabledByDefault
|
||||
@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,
|
||||
@@ -40,15 +30,6 @@ struct TalkProTab: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
self.header
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
TalkRuntimeIssueBanner(
|
||||
issue: fallbackIssue,
|
||||
onOpenSettings: self.openSettings,
|
||||
onShowDetails: {
|
||||
self.showTalkIssueDetails = true
|
||||
})
|
||||
.padding(.horizontal, OpenClawProMetric.pagePadding)
|
||||
}
|
||||
self.voiceHeroCard
|
||||
self.conversationCard
|
||||
self.voiceModeCard
|
||||
@@ -81,22 +62,11 @@ struct TalkProTab: View {
|
||||
.presentationDetents([.medium, .large])
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
.sheet(isPresented: self.$showTalkIssueDetails) {
|
||||
if let fallbackIssue = self.fallbackIssue {
|
||||
TalkRuntimeIssueDetailsSheet(
|
||||
issue: fallbackIssue,
|
||||
onOpenSettings: self.openSettings)
|
||||
.openClawSheetChrome()
|
||||
}
|
||||
}
|
||||
.onAppear { self.alignPersistedTalkState() }
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -203,21 +173,9 @@ struct TalkProTab: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 11)
|
||||
.padding(.bottom, 3)
|
||||
self.infoRow(
|
||||
icon: "waveform",
|
||||
title: "Configured",
|
||||
value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(
|
||||
icon: "waveform",
|
||||
title: "Active now",
|
||||
value: self.activeModeText)
|
||||
self.infoRow(icon: "waveform", title: "Mode", value: self.appModel.talkMode.gatewayTalkVoiceModeTitle)
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "antenna.radiowaves.left.and.right", title: "Transport", value: self.transportText)
|
||||
if let issueText = self.talkIssueText {
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "exclamationmark.triangle.fill", title: "Last issue", value: issueText)
|
||||
}
|
||||
Divider().padding(.leading, 54)
|
||||
self.infoRow(icon: "key.fill", title: "Permission", value: self.permissionText)
|
||||
Divider().padding(.leading, 54)
|
||||
@@ -329,11 +287,6 @@ struct TalkProTab: View {
|
||||
GatewayStatusBuilder.build(appModel: self.appModel) == .connected
|
||||
}
|
||||
|
||||
private var fallbackIssue: TalkRuntimeIssue? {
|
||||
guard self.gatewayConnected else { return nil }
|
||||
return self.appModel.talkMode.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
private var headerSubtitle: String {
|
||||
let mode = self.appModel.talkMode.gatewayTalkVoiceModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let agent = self.appModel.chatAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -364,21 +317,6 @@ struct TalkProTab: View {
|
||||
return "\(provider) • \(transport)"
|
||||
}
|
||||
|
||||
private var activeModeText: String {
|
||||
let title = self.appModel.talkMode.gatewayTalkActiveModeTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let subtitle = (self.appModel.talkMode.gatewayTalkActiveModeSubtitle ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if title.isEmpty { return "Not active" }
|
||||
if subtitle.isEmpty { return title }
|
||||
return "\(title) • \(subtitle)"
|
||||
}
|
||||
|
||||
private var talkIssueText: String? {
|
||||
let text = (self.appModel.talkMode.gatewayTalkLastIssueText ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
private var permissionText: String {
|
||||
if let failure = self.appModel.talkMode.gatewayTalkPermissionState.failureMessage {
|
||||
return failure
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct TalkRuntimeIssueBanner: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
var onShowDetails: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.iconName)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(self.tint)
|
||||
.frame(width: 20)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(self.issue.fallbackBannerTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 0)
|
||||
Text(self.issue.fallbackBannerOwnerLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.issue.fallbackBannerMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(self.issue.displayMessage)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(self.tint)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
if let onOpenSettings {
|
||||
Button("Open Settings", action: onOpenSettings)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
if let onShowDetails {
|
||||
Button("Details", action: onShowDetails)
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(13)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(.ultraThickMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(Color.primary.opacity(self.colorScheme == .dark ? 0.12 : 0.07), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(self.colorScheme == .dark ? 0.16 : 0.07), radius: 16, y: 7)
|
||||
}
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
"exclamationmark.triangle.fill"
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
.orange
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkRuntimeIssueDetailsSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let issue: TalkRuntimeIssue
|
||||
var onOpenSettings: (() -> Void)?
|
||||
|
||||
@State private var copyFeedback: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(self.issue.fallbackBannerTitle)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text(self.issue.fallbackBannerMessage)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.issue.displayMessage)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Technical details") {
|
||||
Text(verbatim: self.issue.technicalDetails)
|
||||
.font(.system(.footnote, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
Button("Copy diagnostics") {
|
||||
UIPasteboard.general.string = self.issue.technicalDetails
|
||||
self.copyFeedback = "Copied diagnostics"
|
||||
}
|
||||
}
|
||||
|
||||
if let copyFeedback {
|
||||
Section {
|
||||
Text(copyFeedback)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Talk fallback")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if let onOpenSettings {
|
||||
Button("Open Settings") {
|
||||
self.dismiss()
|
||||
onOpenSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
self.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,6 @@ final class RealtimeTalkRelaySession {
|
||||
let failed: Bool
|
||||
}
|
||||
|
||||
private enum StartupWaitResult {
|
||||
case ready
|
||||
case failed(TalkRuntimeIssue)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private nonisolated static let expectedInputEncoding = "pcm16"
|
||||
private nonisolated static let expectedOutputEncoding = "pcm16"
|
||||
private nonisolated static let defaultSampleRateHz = 24000
|
||||
@@ -116,23 +110,16 @@ final class RealtimeTalkRelaySession {
|
||||
private nonisolated static let bargeInRmsThreshold: Float = 0.08
|
||||
private nonisolated static let bargeInCooldownMs: Double = 900
|
||||
private nonisolated static let minOutputBeforeBargeInMs: Double = 250
|
||||
private nonisolated static let startupReadyTimeoutSeconds = 12
|
||||
|
||||
private let gateway: GatewayNodeSession
|
||||
private let options: Options
|
||||
private let pcmPlayer: PCMStreamingAudioPlaying
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "RealtimeTalkRelay")
|
||||
private let onStatus: (String) -> Void
|
||||
private let onIssue: (TalkRuntimeIssue) -> Void
|
||||
private let onSpeakingChanged: (Bool) -> Void
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private var relaySessionId: String?
|
||||
private var hasReceivedReady = false
|
||||
private var hasReceivedFailure = false
|
||||
private var startupIssue: TalkRuntimeIssue?
|
||||
private var startupWaiter: CheckedContinuation<StartupWaitResult, Never>?
|
||||
private var pendingPreRelayEvents: [EventFrame] = []
|
||||
private var inputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz)
|
||||
private var outputSampleRateHz = Double(RealtimeTalkRelaySession.defaultSampleRateHz)
|
||||
private var eventTask: Task<Void, Never>?
|
||||
@@ -164,53 +151,34 @@ final class RealtimeTalkRelaySession {
|
||||
options: Options,
|
||||
pcmPlayer: PCMStreamingAudioPlaying,
|
||||
onStatus: @escaping (String) -> Void,
|
||||
onIssue: @escaping (TalkRuntimeIssue) -> Void = { _ in },
|
||||
onSpeakingChanged: @escaping (Bool) -> Void)
|
||||
{
|
||||
self.gateway = gateway
|
||||
self.options = options
|
||||
self.pcmPlayer = pcmPlayer
|
||||
self.onStatus = onStatus
|
||||
self.onIssue = onIssue
|
||||
self.onSpeakingChanged = onSpeakingChanged
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
self.isClosed = false
|
||||
self.hasReceivedReady = false
|
||||
self.hasReceivedFailure = false
|
||||
self.startupIssue = nil
|
||||
self.startupWaiter = nil
|
||||
self.pendingPreRelayEvents.removeAll()
|
||||
self.onStatus("Connecting realtime…")
|
||||
let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.startEventPump(stream: eventStream)
|
||||
let result = try await self.createRelaySession()
|
||||
guard let relaySessionId = result.relaysessionid?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!relaySessionId.isEmpty
|
||||
else {
|
||||
throw NSError(domain: "RealtimeTalkRelay", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway did not return a realtime relay session",
|
||||
])
|
||||
}
|
||||
self.relaySessionId = relaySessionId
|
||||
do {
|
||||
let result = try await self.createRelaySession()
|
||||
guard let relaySessionId = result.relaysessionid?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!relaySessionId.isEmpty
|
||||
else {
|
||||
throw NSError(domain: "RealtimeTalkRelay", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Gateway did not return a realtime relay session",
|
||||
])
|
||||
}
|
||||
self.relaySessionId = relaySessionId
|
||||
self.audioSender = RealtimeAudioSender(gateway: self.gateway, relaySessionId: relaySessionId)
|
||||
let eventStream = await self.gateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
self.startEventPump(stream: eventStream)
|
||||
self.configureAudioContract(result.audio)
|
||||
try self.startMicrophonePump()
|
||||
self.onStatus("Waiting for realtime…")
|
||||
await self.drainPendingPreRelayEvents()
|
||||
switch await self.waitForStartupResult(timeoutSeconds: Self.startupReadyTimeoutSeconds) {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(issue):
|
||||
self.close(sendClose: true)
|
||||
throw NSError(domain: "RealtimeTalkRelay", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: issue.displayMessage,
|
||||
])
|
||||
case .cancelled:
|
||||
return
|
||||
}
|
||||
self.onStatus("Listening (Realtime)")
|
||||
} catch {
|
||||
let createdRelaySessionId = self.relaySessionId
|
||||
self.close(sendClose: false)
|
||||
@@ -228,7 +196,6 @@ final class RealtimeTalkRelaySession {
|
||||
private func close(sendClose: Bool) {
|
||||
guard !self.isClosed else { return }
|
||||
self.isClosed = true
|
||||
self.finishStartupWait(.cancelled)
|
||||
self.stopMicrophonePump()
|
||||
self.eventTask?.cancel()
|
||||
self.eventTask = nil
|
||||
@@ -332,21 +299,14 @@ final class RealtimeTalkRelaySession {
|
||||
guard event.event == "talk.event",
|
||||
let payload = event.payload?.dictionaryValue
|
||||
else { return }
|
||||
guard let relaySessionId else {
|
||||
self.pendingPreRelayEvents.append(event)
|
||||
if self.pendingPreRelayEvents.count > 200 {
|
||||
self.pendingPreRelayEvents.removeFirst(self.pendingPreRelayEvents.count - 200)
|
||||
}
|
||||
return
|
||||
}
|
||||
if payload["relaySessionId"]?.stringValue != relaySessionId {
|
||||
if let relaySessionId,
|
||||
payload["relaySessionId"]?.stringValue != relaySessionId
|
||||
{
|
||||
return
|
||||
}
|
||||
guard let type = payload["type"]?.stringValue else { return }
|
||||
switch type {
|
||||
case "ready":
|
||||
self.hasReceivedReady = true
|
||||
self.finishStartupWait(.ready)
|
||||
self.onStatus("Listening (Realtime)")
|
||||
case "audio":
|
||||
guard let base64 = payload["audioBase64"]?.stringValue,
|
||||
@@ -371,107 +331,17 @@ final class RealtimeTalkRelaySession {
|
||||
await self.handleToolCall(payload)
|
||||
case "error":
|
||||
let message = payload["message"]?.stringValue ?? "Realtime failed"
|
||||
let issue = Self.issue(
|
||||
payload: payload,
|
||||
fallbackMessage: message,
|
||||
fallbackProvider: self.options.provider,
|
||||
fallbackModel: self.options.model)
|
||||
GatewayDiagnostics.log("talk realtime: error=\(Self.safeLogMessage(message))")
|
||||
self.hasReceivedFailure = true
|
||||
self.startupIssue = issue
|
||||
self.onIssue(issue)
|
||||
self.finishStartupWait(.failed(issue))
|
||||
self.onStatus(message)
|
||||
case "close":
|
||||
GatewayDiagnostics.log("talk realtime: close")
|
||||
if self.hasReceivedReady {
|
||||
self.onStatus("Ready")
|
||||
} else if !self.hasReceivedFailure {
|
||||
let issue = TalkRuntimeIssue(
|
||||
code: .realtimeUnavailable,
|
||||
message: "Realtime closed before it became ready.",
|
||||
provider: self.options.provider,
|
||||
model: self.options.model,
|
||||
transport: "gateway-relay",
|
||||
phase: "connect")
|
||||
self.onIssue(issue)
|
||||
self.startupIssue = issue
|
||||
self.finishStartupWait(.failed(issue))
|
||||
self.onStatus("Realtime failed before connecting")
|
||||
}
|
||||
self.onStatus("Ready")
|
||||
self.close(sendClose: false)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForStartupResult(timeoutSeconds: Int) async -> StartupWaitResult {
|
||||
if self.isClosed { return .cancelled }
|
||||
if self.hasReceivedReady { return .ready }
|
||||
if let startupIssue { return .failed(startupIssue) }
|
||||
return await withCheckedContinuation { continuation in
|
||||
if self.isClosed {
|
||||
continuation.resume(returning: .cancelled)
|
||||
return
|
||||
}
|
||||
self.startupWaiter = continuation
|
||||
Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(max(0, timeoutSeconds)) * 1_000_000_000)
|
||||
await self?.timeoutStartupWaiterIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func drainPendingPreRelayEvents() async {
|
||||
let pendingEvents = self.pendingPreRelayEvents
|
||||
self.pendingPreRelayEvents.removeAll()
|
||||
for event in pendingEvents {
|
||||
await self.handleGatewayEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishStartupWait(_ result: StartupWaitResult) {
|
||||
guard let waiter = self.startupWaiter else { return }
|
||||
self.startupWaiter = nil
|
||||
waiter.resume(returning: result)
|
||||
}
|
||||
|
||||
private func timeoutStartupWaiterIfNeeded() {
|
||||
guard !self.isClosed, self.startupWaiter != nil, !self.hasReceivedReady, self.startupIssue == nil else {
|
||||
return
|
||||
}
|
||||
let issue = TalkRuntimeIssue(
|
||||
code: .realtimeUnavailable,
|
||||
message: "Realtime did not become ready in time.",
|
||||
provider: self.options.provider,
|
||||
model: self.options.model,
|
||||
transport: "gateway-relay",
|
||||
phase: "connect")
|
||||
self.hasReceivedFailure = true
|
||||
self.startupIssue = issue
|
||||
self.onIssue(issue)
|
||||
self.onStatus(issue.displayMessage)
|
||||
self.finishStartupWait(.failed(issue))
|
||||
}
|
||||
|
||||
private static func issue(
|
||||
payload: [String: AnyCodable],
|
||||
fallbackMessage: String,
|
||||
fallbackProvider: String?,
|
||||
fallbackModel: String?) -> TalkRuntimeIssue
|
||||
{
|
||||
let provider = payload["provider"]?.stringValue ?? fallbackProvider
|
||||
let model = payload["model"]?.stringValue ?? fallbackModel
|
||||
let transport = payload["transport"]?.stringValue ?? "gateway-relay"
|
||||
let phase = payload["phase"]?.stringValue
|
||||
return TalkRuntimeIssue.realtimeUnavailable(
|
||||
message: fallbackMessage,
|
||||
provider: provider,
|
||||
model: model,
|
||||
transport: transport,
|
||||
phase: phase)
|
||||
}
|
||||
|
||||
private func recordOutputAudioChunk(byteCount: Int) {
|
||||
self.outputAudioChunkCount += 1
|
||||
self.outputAudioByteCount += byteCount
|
||||
@@ -934,25 +804,6 @@ final class RealtimeTalkRelaySession {
|
||||
}
|
||||
|
||||
extension RealtimeTalkRelaySession {
|
||||
func _test_setRelaySessionId(_ relaySessionId: String) {
|
||||
self.relaySessionId = relaySessionId
|
||||
}
|
||||
|
||||
func _test_handleGatewayEvent(_ event: EventFrame) async {
|
||||
await self.handleGatewayEvent(event)
|
||||
}
|
||||
|
||||
func _test_waitForStartupCancelled(timeoutSeconds: Int) async -> Bool {
|
||||
if case .cancelled = await self.waitForStartupResult(timeoutSeconds: timeoutSeconds) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func _test_startupReadyTimeoutSeconds() -> Int {
|
||||
Self.startupReadyTimeoutSeconds
|
||||
}
|
||||
|
||||
func _test_markOutputAudioStarted(nowMs: Double) {
|
||||
self.markOutputAudioStarted(byteCount: 4800, nowMs: nowMs)
|
||||
}
|
||||
|
||||
@@ -7,96 +7,6 @@ enum TalkModeExecutionMode {
|
||||
case realtimeRelay
|
||||
}
|
||||
|
||||
struct TalkRuntimeIssue: Equatable {
|
||||
enum Code: String {
|
||||
case realtimeUnavailable = "realtime_unavailable"
|
||||
}
|
||||
|
||||
let code: Code
|
||||
let message: String
|
||||
let provider: String?
|
||||
let model: String?
|
||||
let transport: String?
|
||||
let phase: String?
|
||||
let occurredAt: Date
|
||||
|
||||
init(
|
||||
code: Code,
|
||||
message: String,
|
||||
provider: String? = nil,
|
||||
model: String? = nil,
|
||||
transport: String? = nil,
|
||||
phase: String? = nil,
|
||||
occurredAt: Date = Date())
|
||||
{
|
||||
self.code = code
|
||||
self.message = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.provider = provider?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.model = model?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.transport = transport?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.phase = phase?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.occurredAt = occurredAt
|
||||
}
|
||||
|
||||
var displayMessage: String {
|
||||
if !self.message.isEmpty { return self.message }
|
||||
return "Realtime voice did not start."
|
||||
}
|
||||
|
||||
var fallbackStatusText: String {
|
||||
"Listening (iOS Speech fallback)"
|
||||
}
|
||||
|
||||
var fallbackBannerTitle: String {
|
||||
"Using iOS Speech fallback"
|
||||
}
|
||||
|
||||
var fallbackBannerOwnerLabel: String {
|
||||
"Fallback active"
|
||||
}
|
||||
|
||||
var fallbackBannerMessage: String {
|
||||
"Realtime voice did not start. Talk is running with iOS speech recognition and TTS."
|
||||
}
|
||||
|
||||
var technicalDetails: String {
|
||||
var lines = [
|
||||
"code: \(self.code.rawValue)",
|
||||
"message: \(self.displayMessage)",
|
||||
]
|
||||
if let provider, !provider.isEmpty { lines.append("provider: \(provider)") }
|
||||
if let model, !model.isEmpty { lines.append("model: \(model)") }
|
||||
if let transport, !transport.isEmpty { lines.append("transport: \(transport)") }
|
||||
if let phase, !phase.isEmpty { lines.append("phase: \(phase)") }
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
var diagnosticSummary: String {
|
||||
var parts = [self.displayMessage]
|
||||
if let provider, !provider.isEmpty { parts.append("provider: \(provider)") }
|
||||
if let model, !model.isEmpty { parts.append("model: \(model)") }
|
||||
if let transport, !transport.isEmpty { parts.append("transport: \(transport)") }
|
||||
if let phase, !phase.isEmpty { parts.append("phase: \(phase)") }
|
||||
return parts.joined(separator: " • ")
|
||||
}
|
||||
|
||||
static func realtimeUnavailable(
|
||||
message: String,
|
||||
provider: String? = nil,
|
||||
model: String? = nil,
|
||||
transport: String? = nil,
|
||||
phase: String? = nil) -> TalkRuntimeIssue
|
||||
{
|
||||
TalkRuntimeIssue(
|
||||
code: .realtimeUnavailable,
|
||||
message: message,
|
||||
provider: provider,
|
||||
model: model,
|
||||
transport: transport,
|
||||
phase: phase)
|
||||
}
|
||||
}
|
||||
|
||||
struct TalkVoiceModeDescriptor: Equatable {
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
|
||||
@@ -60,10 +60,6 @@ final class TalkModeManager: NSObject {
|
||||
var gatewayTalkVoiceModeTitle: String = "Not loaded"
|
||||
var gatewayTalkVoiceModeSubtitle: String?
|
||||
var gatewayTalkVoiceModeAccessibilityValue: String = "Not loaded"
|
||||
var gatewayTalkActiveModeTitle: String = "Not active"
|
||||
var gatewayTalkActiveModeSubtitle: String?
|
||||
var gatewayTalkLastIssueText: String?
|
||||
var gatewayTalkCurrentFallbackIssue: TalkRuntimeIssue?
|
||||
var gatewayTalkPermissionState: TalkGatewayPermissionState = .unknown
|
||||
|
||||
var isGatewayConnected: Bool {
|
||||
@@ -81,12 +77,6 @@ final class TalkModeManager: NSObject {
|
||||
case pushToTalk
|
||||
}
|
||||
|
||||
private enum RealtimeStartResult {
|
||||
case started
|
||||
case unavailable(TalkRuntimeIssue)
|
||||
case ignored
|
||||
}
|
||||
|
||||
private var isStarting = false
|
||||
private var startAttemptID = 0
|
||||
private var captureMode: CaptureMode = .idle
|
||||
@@ -139,8 +129,6 @@ final class TalkModeManager: NSObject {
|
||||
voiceId: nil,
|
||||
transport: nil,
|
||||
isRealtime: false)
|
||||
private var pendingRealtimeIssue: TalkRuntimeIssue?
|
||||
private var realtimeRelayStartIssue: TalkRuntimeIssue?
|
||||
private var apiKey: String?
|
||||
private var voiceAliases: [String: String] = [:]
|
||||
private var interruptOnSpeech: Bool = true
|
||||
@@ -204,8 +192,6 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
} else {
|
||||
self.stopRealtimeSession()
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
if self.isEnabled, !self.isSpeaking {
|
||||
self.statusText = "Offline"
|
||||
}
|
||||
@@ -313,15 +299,11 @@ final class TalkModeManager: NSObject {
|
||||
return
|
||||
}
|
||||
if self.realtimeWebRTCEnabled {
|
||||
let realtimeStart = self.executionMode == .realtimeRelay
|
||||
let started = self.executionMode == .realtimeRelay
|
||||
? await self.startRealtimeRelayIfAvailable()
|
||||
: await self.startRealtimeIfAvailable()
|
||||
switch realtimeStart {
|
||||
case .started, .ignored:
|
||||
if started {
|
||||
return
|
||||
case let .unavailable(issue):
|
||||
self.pendingRealtimeIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -342,11 +324,7 @@ final class TalkModeManager: NSObject {
|
||||
self.captureMode = .continuous
|
||||
try self.startRecognition()
|
||||
self.isListening = true
|
||||
if let issue = self.pendingRealtimeIssue {
|
||||
self.markNativeFallbackActive(after: issue)
|
||||
} else {
|
||||
self.markNativeTalkActive()
|
||||
}
|
||||
self.statusText = "Listening"
|
||||
self.startSilenceMonitor()
|
||||
await self.subscribeChatIfNeeded(sessionKey: self.mainSessionKey)
|
||||
self.logger.info("listening")
|
||||
@@ -401,11 +379,6 @@ final class TalkModeManager: NSObject {
|
||||
self.isPushToTalkActive = false
|
||||
self.captureMode = .idle
|
||||
self.statusText = "Off"
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.lastTranscript = ""
|
||||
self.lastHeard = nil
|
||||
self.silenceTask?.cancel()
|
||||
@@ -452,8 +425,6 @@ final class TalkModeManager: NSObject {
|
||||
self.isPushToTalkActive = false
|
||||
self.captureMode = .idle
|
||||
self.statusText = "Paused"
|
||||
self.gatewayTalkActiveModeTitle = "Paused"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.lastTranscript = ""
|
||||
self.lastHeard = nil
|
||||
self.silenceTask?.cancel()
|
||||
@@ -1076,10 +1047,8 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func startRealtimeIfAvailable() async -> RealtimeStartResult {
|
||||
guard let gateway else {
|
||||
return .unavailable(self.realtimeIssue(message: "Gateway not connected", phase: "start"))
|
||||
}
|
||||
private func startRealtimeIfAvailable() async -> Bool {
|
||||
guard let gateway else { return false }
|
||||
let startedAt = Self.nowSeconds()
|
||||
if self.prefetchedRealtimeSession == nil, let prefetchTask = self.realtimePrefetchTask {
|
||||
GatewayDiagnostics.log("talk.timeline realtime awaiting in-flight prefetch")
|
||||
@@ -1100,53 +1069,49 @@ final class TalkModeManager: NSObject {
|
||||
prefetchedSession: prefetchedSession)
|
||||
guard self.realtimeSession === session, self.isEnabled else {
|
||||
session.stop()
|
||||
return .ignored
|
||||
return true
|
||||
}
|
||||
self.isListening = true
|
||||
self.captureMode = .continuous
|
||||
self.markRealtimeActive()
|
||||
self.statusText = "Listening"
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime start ready elapsedMs=\(Self.elapsedMs(since: startedAt))")
|
||||
GatewayDiagnostics.log("talk realtime: started direct OpenAI WebRTC session")
|
||||
return .started
|
||||
return true
|
||||
} catch {
|
||||
guard self.realtimeSession === session, self.isEnabled else {
|
||||
session.stop()
|
||||
return .ignored
|
||||
return true
|
||||
}
|
||||
self.stopRealtimeSession()
|
||||
let issue = self.realtimeIssue(from: error, phase: "start")
|
||||
GatewayDiagnostics
|
||||
.log("talk realtime: unavailable; falling back to speech pipeline error=\(error.localizedDescription)")
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime start failed elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "error=\(error.localizedDescription)")
|
||||
return .unavailable(issue)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func startRealtimeRelayIfAvailable() async -> RealtimeStartResult {
|
||||
guard let gateway else {
|
||||
return .unavailable(self.realtimeIssue(message: "Gateway not connected", phase: "start"))
|
||||
}
|
||||
private func startRealtimeRelayIfAvailable() async -> Bool {
|
||||
guard let gateway else { return false }
|
||||
guard self.foregroundAudioCaptureAllowed else {
|
||||
self.statusText = "Paused"
|
||||
GatewayDiagnostics.log("talk realtime ignored: app backgrounded")
|
||||
return .ignored
|
||||
return true
|
||||
}
|
||||
if self.realtimeRelaySession != nil {
|
||||
self.captureMode = .continuous
|
||||
self.isListening = true
|
||||
GatewayDiagnostics.log("talk realtime ignored: already active")
|
||||
return .started
|
||||
return true
|
||||
}
|
||||
guard !self.realtimeRelayStartInFlight else {
|
||||
GatewayDiagnostics.log("talk realtime ignored: already starting")
|
||||
return .ignored
|
||||
return true
|
||||
}
|
||||
self.realtimeRelayStartInFlight = true
|
||||
defer { self.realtimeRelayStartInFlight = false }
|
||||
self.prepareRealtimeRelayStart()
|
||||
GatewayDiagnostics.log("talk.timeline realtime relay start attempt sessionKey=\(self.mainSessionKey)")
|
||||
let startedAt = Self.nowSeconds()
|
||||
let relaySession = RealtimeTalkRelaySession(
|
||||
@@ -1159,15 +1124,13 @@ final class TalkModeManager: NSObject {
|
||||
pcmPlayer: self.pcmPlayer,
|
||||
onStatus: { [weak self] status in
|
||||
guard let self else { return }
|
||||
self.handleRealtimeRelayStatus(status)
|
||||
},
|
||||
onIssue: { [weak self] issue in
|
||||
guard let self else { return }
|
||||
self.realtimeRelayStartIssue = issue
|
||||
self.pendingRealtimeIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
self.gatewayTalkActiveModeTitle = "Realtime unavailable"
|
||||
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
|
||||
self.statusText = status
|
||||
self.isListening = status.localizedCaseInsensitiveContains("listening")
|
||||
if status.localizedCaseInsensitiveContains("thinking") {
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
}
|
||||
},
|
||||
onSpeakingChanged: { [weak self] speaking in
|
||||
guard let self else { return }
|
||||
@@ -1182,35 +1145,23 @@ final class TalkModeManager: NSObject {
|
||||
try await relaySession.start()
|
||||
guard self.realtimeRelaySession === relaySession, self.isEnabled else {
|
||||
relaySession.stop()
|
||||
return .ignored
|
||||
}
|
||||
if let issue = self.realtimeRelayStartIssue {
|
||||
self.realtimeRelaySession = nil
|
||||
relaySession.stop()
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime relay start unavailable elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "issue=\(issue.code.rawValue)")
|
||||
return .unavailable(issue)
|
||||
return true
|
||||
}
|
||||
self.isListening = true
|
||||
self.captureMode = .continuous
|
||||
self.realtimeRelayStartIssue = nil
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime relay start ready elapsedMs=\(Self.elapsedMs(since: startedAt))")
|
||||
return .started
|
||||
return true
|
||||
} catch {
|
||||
guard self.realtimeRelaySession === relaySession, self.isEnabled else {
|
||||
relaySession.stop()
|
||||
return .ignored
|
||||
return true
|
||||
}
|
||||
self.realtimeRelaySession = nil
|
||||
let issue = self.realtimeRelayStartIssue
|
||||
?? self.realtimeIssue(from: error, phase: "start")
|
||||
self.realtimeRelayStartIssue = nil
|
||||
GatewayDiagnostics.log(
|
||||
"talk.timeline realtime relay start failed elapsedMs=\(Self.elapsedMs(since: startedAt)) "
|
||||
+ "error=\(error.localizedDescription)")
|
||||
return .unavailable(issue)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2412,103 +2363,6 @@ extension TalkModeManager {
|
||||
self.gatewayTalkVoiceModeAccessibilityValue = descriptor.accessibilityValue
|
||||
}
|
||||
|
||||
private func markRealtimeActive() {
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkLastIssueText = nil
|
||||
self.gatewayTalkActiveModeTitle = self.configuredVoiceModeDescriptor.title
|
||||
self.gatewayTalkActiveModeSubtitle = self.configuredVoiceModeDescriptor.subtitle
|
||||
self.statusText = "Listening (Realtime)"
|
||||
}
|
||||
|
||||
private func handleRealtimeRelayStatus(_ status: String) {
|
||||
if status == "Listening (Realtime)" {
|
||||
self.markRealtimeActive()
|
||||
} else {
|
||||
self.statusText = status
|
||||
if status == "Ready" {
|
||||
self.realtimeRelaySession = nil
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
}
|
||||
}
|
||||
self.isListening = status.localizedCaseInsensitiveContains("listening")
|
||||
if status.localizedCaseInsensitiveContains("thinking") {
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareRealtimeRelayStart() {
|
||||
self.realtimeRelayStartIssue = nil
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
}
|
||||
|
||||
private func markNativeTalkActive() {
|
||||
self.pendingRealtimeIssue = nil
|
||||
self.gatewayTalkCurrentFallbackIssue = nil
|
||||
self.gatewayTalkActiveModeTitle = "iOS Speech + TTS"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
self.statusText = "Listening"
|
||||
}
|
||||
|
||||
private func markNativeFallbackActive(after issue: TalkRuntimeIssue) {
|
||||
self.gatewayTalkActiveModeTitle = "iOS Speech fallback"
|
||||
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
|
||||
self.gatewayTalkCurrentFallbackIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
self.statusText = issue.fallbackStatusText
|
||||
}
|
||||
|
||||
private func realtimeIssue(message: String, phase: String) -> TalkRuntimeIssue {
|
||||
TalkRuntimeIssue.realtimeUnavailable(
|
||||
message: message,
|
||||
provider: self.realtimeProvider,
|
||||
model: self.realtimeModelId,
|
||||
transport: self.executionMode == .realtimeRelay ? "gateway-relay" : "webrtc",
|
||||
phase: phase)
|
||||
}
|
||||
|
||||
private func realtimeIssue(from error: Error, phase: String) -> TalkRuntimeIssue {
|
||||
if let gatewayError = error as? GatewayResponseError,
|
||||
let issue = Self.talkRuntimeIssue(
|
||||
from: gatewayError,
|
||||
fallbackProvider: self.realtimeProvider,
|
||||
fallbackModel: self.realtimeModelId,
|
||||
fallbackTransport: self.executionMode == .realtimeRelay ? "gateway-relay" : "webrtc",
|
||||
fallbackPhase: phase)
|
||||
{
|
||||
return issue
|
||||
}
|
||||
return self.realtimeIssue(message: error.localizedDescription, phase: phase)
|
||||
}
|
||||
|
||||
private static func talkRuntimeIssue(
|
||||
from gatewayError: GatewayResponseError,
|
||||
fallbackProvider: String?,
|
||||
fallbackModel: String?,
|
||||
fallbackTransport: String?,
|
||||
fallbackPhase: String) -> TalkRuntimeIssue?
|
||||
{
|
||||
guard let rawIssue = gatewayError.details["talkIssue"]?.dictionaryValue else { return nil }
|
||||
let message = rawIssue["message"]?.stringValue ?? gatewayError.message
|
||||
let provider = rawIssue["provider"]?.stringValue ?? fallbackProvider
|
||||
let model = rawIssue["model"]?.stringValue ?? fallbackModel
|
||||
let transport = rawIssue["transport"]?.stringValue ?? fallbackTransport
|
||||
let phase = rawIssue["phase"]?.stringValue ?? fallbackPhase
|
||||
return TalkRuntimeIssue.realtimeUnavailable(
|
||||
message: message,
|
||||
provider: provider,
|
||||
model: model,
|
||||
transport: transport,
|
||||
phase: phase)
|
||||
}
|
||||
|
||||
private func restoreConfiguredVoiceModeDescriptor() {
|
||||
self.applyVoiceModeDescriptor(self.configuredVoiceModeDescriptor)
|
||||
}
|
||||
@@ -2982,11 +2836,7 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate {
|
||||
func realtimeSession(_ session: TalkRealtimeWebRTCSession, didChangeStatus status: String) {
|
||||
guard session === self.realtimeSession else { return }
|
||||
GatewayDiagnostics.log("talk.timeline realtime status=\(status)")
|
||||
if status == "Listening" {
|
||||
self.markRealtimeActive()
|
||||
} else {
|
||||
self.statusText = status
|
||||
}
|
||||
self.statusText = status
|
||||
self.isListening = status == "Listening"
|
||||
self.isSpeaking = status == "Speaking"
|
||||
if status == "Thinking" {
|
||||
@@ -3027,8 +2877,6 @@ extension TalkModeManager: TalkRealtimeWebRTCSessionDelegate {
|
||||
self.isListening = false
|
||||
self.isSpeaking = false
|
||||
self.isUserSpeechDetected = false
|
||||
self.gatewayTalkActiveModeTitle = "Not active"
|
||||
self.gatewayTalkActiveModeSubtitle = nil
|
||||
if self.isEnabled {
|
||||
self.statusText = self.gatewayConnected ? "Ready" : "Offline"
|
||||
}
|
||||
@@ -3061,49 +2909,6 @@ extension TalkModeManager {
|
||||
self.gatewayTalkUsesRealtimeRelay
|
||||
}
|
||||
|
||||
func _test_markNativeFallbackActive(after issue: TalkRuntimeIssue) {
|
||||
self.markNativeFallbackActive(after: issue)
|
||||
}
|
||||
|
||||
func _test_recordRealtimeIssue(_ issue: TalkRuntimeIssue) {
|
||||
self.pendingRealtimeIssue = issue
|
||||
self.gatewayTalkLastIssueText = issue.diagnosticSummary
|
||||
self.gatewayTalkActiveModeTitle = "Realtime unavailable"
|
||||
self.gatewayTalkActiveModeSubtitle = issue.displayMessage
|
||||
}
|
||||
|
||||
func _test_handleRealtimeRelayStatus(_ status: String) {
|
||||
self.handleRealtimeRelayStatus(status)
|
||||
}
|
||||
|
||||
func _test_prepareRealtimeRelayStart() {
|
||||
self.prepareRealtimeRelayStart()
|
||||
}
|
||||
|
||||
func _test_realtimeIssue(from error: Error, phase: String) -> TalkRuntimeIssue {
|
||||
self.realtimeIssue(from: error, phase: phase)
|
||||
}
|
||||
|
||||
func _test_hasPendingRealtimeIssue() -> Bool {
|
||||
self.pendingRealtimeIssue != nil
|
||||
}
|
||||
|
||||
func _test_gatewayTalkActiveModeTitle() -> String {
|
||||
self.gatewayTalkActiveModeTitle
|
||||
}
|
||||
|
||||
func _test_gatewayTalkActiveModeSubtitle() -> String? {
|
||||
self.gatewayTalkActiveModeSubtitle
|
||||
}
|
||||
|
||||
func _test_gatewayTalkLastIssueText() -> String? {
|
||||
self.gatewayTalkLastIssueText
|
||||
}
|
||||
|
||||
func _test_gatewayTalkCurrentFallbackIssue() -> TalkRuntimeIssue? {
|
||||
self.gatewayTalkCurrentFallbackIssue
|
||||
}
|
||||
|
||||
func _test_seedTranscript(_ transcript: String) {
|
||||
self.lastTranscript = transcript
|
||||
self.lastHeard = Date()
|
||||
|
||||
@@ -21,7 +21,6 @@ Sources/Design/SettingsProTab.swift
|
||||
Sources/Design/SettingsProTabSupport.swift
|
||||
Sources/Design/SettingsProTabSections.swift
|
||||
Sources/Design/SettingsProTabActions.swift
|
||||
Sources/Design/TalkRuntimeIssueBanner.swift
|
||||
Sources/Design/CommandCenterSupport.swift
|
||||
Sources/Design/AgentProTab+Overview.swift
|
||||
Sources/Design/AgentProTab+Destinations.swift
|
||||
@@ -31,15 +30,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 +72,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 +85,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,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -38,70 +37,4 @@ private final class UnusedPCMStreamingAudioPlayer: PCMStreamingAudioPlaying {
|
||||
session._test_markOutputAudioStarted(nowMs: 500)
|
||||
#expect(session._test_outputStartedAtMs() == 500)
|
||||
}
|
||||
|
||||
@Test func closeAfterClassifiedErrorDoesNotReplaceIssue() async {
|
||||
var issues: [TalkRuntimeIssue] = []
|
||||
var statuses: [String] = []
|
||||
let session = RealtimeTalkRelaySession(
|
||||
gateway: GatewayNodeSession(),
|
||||
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
|
||||
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
||||
onStatus: { statuses.append($0) },
|
||||
onIssue: { issues.append($0) },
|
||||
onSpeakingChanged: { _ in })
|
||||
session._test_setRelaySessionId("relay-1")
|
||||
|
||||
await session._test_handleGatewayEvent(EventFrame(
|
||||
type: "event",
|
||||
event: "talk.event",
|
||||
payload: AnyCodable([
|
||||
"relaySessionId": "relay-1",
|
||||
"type": "error",
|
||||
"message": "OpenAI API key rejected with 401",
|
||||
"code": "realtime_unavailable",
|
||||
"provider": "openai",
|
||||
"model": "gpt-realtime-2",
|
||||
"transport": "gateway-relay",
|
||||
"phase": "connect",
|
||||
]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
await session._test_handleGatewayEvent(EventFrame(
|
||||
type: "event",
|
||||
event: "talk.event",
|
||||
payload: AnyCodable([
|
||||
"relaySessionId": "relay-1",
|
||||
"type": "close",
|
||||
"reason": "error",
|
||||
]),
|
||||
seq: nil,
|
||||
stateversion: nil))
|
||||
|
||||
#expect(issues.map(\.code) == [.realtimeUnavailable])
|
||||
#expect(statuses == ["OpenAI API key rejected with 401"])
|
||||
}
|
||||
|
||||
@Test func closedRelayDoesNotWaitForStartupReady() async {
|
||||
let session = RealtimeTalkRelaySession(
|
||||
gateway: GatewayNodeSession(),
|
||||
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
|
||||
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
||||
onStatus: { _ in },
|
||||
onSpeakingChanged: { _ in })
|
||||
|
||||
session.stop()
|
||||
|
||||
#expect(await session._test_waitForStartupCancelled(timeoutSeconds: 1))
|
||||
}
|
||||
|
||||
@Test func startupReadyWaitCoversGatewayConnectBudget() {
|
||||
let session = RealtimeTalkRelaySession(
|
||||
gateway: GatewayNodeSession(),
|
||||
options: .init(sessionKey: "main", provider: "openai", model: "gpt-realtime-2", voice: nil),
|
||||
pcmPlayer: UnusedPCMStreamingAudioPlayer(),
|
||||
onStatus: { _ in },
|
||||
onSpeakingChanged: { _ in })
|
||||
|
||||
#expect(session._test_startupReadyTimeoutSeconds() >= 12)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user