Compare commits
196 Commits
develop
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
178259e680 | ||
|
|
dbe8b8dc4a | ||
|
|
21d075f0c9 | ||
|
|
e40b125bb8 | ||
|
|
60554db11e | ||
|
|
671ac4d924 | ||
|
|
7546dfaa88 | ||
|
|
451439e4ff | ||
|
|
dea5ad9c6a | ||
|
|
82871daf36 | ||
|
|
d22b4c3769 | ||
|
|
04c741b3ec | ||
|
|
58b54b24f0 | ||
|
|
d3bac0b26d | ||
|
|
cf3f8a6c85 | ||
|
|
60d92ca561 | ||
|
|
8c73dbe705 | ||
|
|
49fb8f74e4 | ||
|
|
5c2cb6c591 | ||
|
|
49c60e9065 | ||
|
|
a97db0c372 | ||
|
|
afec0f11f8 | ||
|
|
97b3ee7ec0 | ||
|
|
fa21050af0 | ||
|
|
661279cbfa | ||
|
|
ffeed212dc | ||
|
|
4df252d895 | ||
|
|
2f9014c6ff | ||
|
|
ae99e656af | ||
|
|
b40a7771e5 | ||
|
|
a172ff9ed2 | ||
|
|
e4a04f32e3 | ||
|
|
1cee5135e4 | ||
|
|
1074d13e4e | ||
|
|
1fad19008e | ||
|
|
65dae9a088 | ||
|
|
0b7e561434 | ||
|
|
dd25b96d0b | ||
|
|
715e8b5440 | ||
|
|
57a598c013 | ||
|
|
de8eb2b29c | ||
|
|
50b3d32d3c | ||
|
|
268094938b | ||
|
|
9a765c9fb4 | ||
|
|
ce71c7326c | ||
|
|
ec55583bb7 | ||
|
|
3e63b2a4fa | ||
|
|
33c75cb6bf | ||
|
|
394d60c1fb | ||
|
|
512b2053c5 | ||
|
|
40b11db80e | ||
|
|
2e4334c32c | ||
|
|
e3ff844bdc | ||
|
|
d311152a7d | ||
|
|
5d4f42016f | ||
|
|
a77afe618d | ||
|
|
29425e27e5 | ||
|
|
c6e142f22e | ||
|
|
3626b07bea | ||
|
|
42a07791c4 | ||
|
|
fb8c653f53 | ||
|
|
588d7133f5 | ||
|
|
582732391a | ||
|
|
b430998c2f | ||
|
|
1c1d7fa0e5 | ||
|
|
a4b38ce886 | ||
|
|
727a390d13 | ||
|
|
a656dcc199 | ||
|
|
0768fc65d2 | ||
|
|
0efaf5aa82 | ||
|
|
6397e53f3a | ||
|
|
24e9b23c4a | ||
|
|
9f4466c116 | ||
|
|
9050a94a0f | ||
|
|
79c2466662 | ||
|
|
f0924d3c4e | ||
|
|
5acb1e3c52 | ||
|
|
ec910a235e | ||
|
|
8968d9a339 | ||
|
|
e4651d6afa | ||
|
|
c984e6d8df | ||
|
|
71b4be8799 | ||
|
|
5e55a181b7 | ||
|
|
5f2ad938aa | ||
|
|
a6cab10976 | ||
|
|
139d70e2a9 | ||
|
|
07375a65d8 | ||
|
|
fb8e4489a3 | ||
|
|
6ed255319f | ||
|
|
8d96955e19 | ||
|
|
0cf93b8fa7 | ||
|
|
d85f0566a9 | ||
|
|
d7bd68ff24 | ||
|
|
5ac1be9cb6 | ||
|
|
69aa3df116 | ||
|
|
53a1ac36f5 | ||
|
|
456bd58740 | ||
|
|
0244d521a1 | ||
|
|
6614c3f932 | ||
|
|
0497bb0544 | ||
|
|
3573f26d40 | ||
|
|
f4fc65d234 | ||
|
|
223eee0a20 | ||
|
|
0b07e15b63 | ||
|
|
92764a60d6 | ||
|
|
eed580d310 | ||
|
|
41f3e90ea8 | ||
|
|
db137dd65d | ||
|
|
c95e6fe6dc | ||
|
|
2b4135debc | ||
|
|
6e3271ebb6 | ||
|
|
d8dbfc701c | ||
|
|
c4213b89eb | ||
|
|
d2ec78607d | ||
|
|
7f7d49aef0 | ||
|
|
6aedc54bd7 | ||
|
|
730f86dd5c | ||
|
|
2f91bf550f | ||
|
|
ad8b839aa7 | ||
|
|
744892de72 | ||
|
|
eb3e9c649b | ||
|
|
a1123dd9be | ||
|
|
74fbbda283 | ||
|
|
28e1a65ebc | ||
|
|
c56fb7f353 | ||
|
|
3119057161 | ||
|
|
cef9bfce22 | ||
|
|
b75d618080 | ||
|
|
e02d144af9 | ||
|
|
9949f82590 | ||
|
|
bc475f0172 | ||
|
|
191da1feb5 | ||
|
|
8fae55e8e0 | ||
|
|
ebe5730401 | ||
|
|
0499656c59 | ||
|
|
05a57e94a4 | ||
|
|
c27b03794a | ||
|
|
9866a857a7 | ||
|
|
e2dea2684f | ||
|
|
a30c4f45c3 | ||
|
|
95263f4e60 | ||
|
|
6f1ba986b3 | ||
|
|
c741d008dd | ||
|
|
0d60ef6fef | ||
|
|
ce715c4c56 | ||
|
|
0deb8b0da1 | ||
|
|
b8c8130efe | ||
|
|
ea423bbbfd | ||
|
|
980f788731 | ||
|
|
9271fcb3d4 | ||
|
|
b8f740fb14 | ||
|
|
8da20027c4 | ||
|
|
9201e140cb | ||
|
|
ff80646085 | ||
|
|
929a3725d3 | ||
|
|
cde29fef71 | ||
|
|
6d1daf2ba5 | ||
|
|
82419eaad6 | ||
|
|
f0722498a4 | ||
|
|
a4d5c7f673 | ||
|
|
9a3f62cb86 | ||
|
|
1007d71f0c | ||
|
|
9f703a44dc | ||
|
|
85ed6c7fa4 | ||
|
|
ad4dd0422e | ||
|
|
4ba9809f18 | ||
|
|
80d42eb0ba | ||
|
|
2b6cf03b47 | ||
|
|
88ffad1c4f | ||
|
|
875324e7c7 | ||
|
|
2d7428a7f2 | ||
|
|
47596257ea | ||
|
|
ab3045cb48 | ||
|
|
aaddbdae52 | ||
|
|
8d0e7997c8 | ||
|
|
31a7e4f937 | ||
|
|
c5194d8148 | ||
|
|
43c0a7fe1c | ||
|
|
0b51f0d762 | ||
|
|
7a9deb2400 | ||
|
|
3997316fb0 | ||
|
|
360851366f | ||
|
|
7af00f040a | ||
|
|
4d30f97407 | ||
|
|
ff948a6dd7 | ||
|
|
ad759c9446 | ||
|
|
9ccbd57016 | ||
|
|
52c9d3480f | ||
|
|
517a8eafe5 | ||
|
|
c8e67ad5d5 | ||
|
|
fb5280e1b5 | ||
|
|
009abd306a | ||
|
|
8c53dfb74f | ||
|
|
7bf4080608 | ||
|
|
1de05ad068 | ||
|
|
30ac80b96b |
126
.agents/skills/PR_WORKFLOW.md
Normal file
126
.agents/skills/PR_WORKFLOW.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# PR Review Instructions
|
||||
|
||||
Please read this in full and do not skip sections.
|
||||
|
||||
## Working rule
|
||||
|
||||
Skills execute workflow, maintainers provide judgment.
|
||||
Always pause between skills to evaluate technical direction, not just command success.
|
||||
|
||||
These three skills must be used in order:
|
||||
|
||||
1. `review-pr`
|
||||
2. `prepare-pr`
|
||||
3. `merge-pr`
|
||||
|
||||
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
|
||||
|
||||
Treat PRs as reports first, code second.
|
||||
If submitted code is low quality, ignore it and implement the best solution for the problem.
|
||||
|
||||
Do not continue if you cannot verify the problem is real or test the fix.
|
||||
|
||||
## PR quality bar
|
||||
|
||||
- Do not trust PR code by default.
|
||||
- Do not merge changes you cannot validate with a reproducible problem and a tested fix.
|
||||
- Keep types strict. Do not use `any` in implementation code.
|
||||
- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output.
|
||||
- Keep implementations properly scoped. Fix root causes, not local symptoms.
|
||||
- Identify and reuse canonical sources of truth so behavior does not drift across the codebase.
|
||||
- Harden changes. Always evaluate security impact and abuse paths.
|
||||
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
|
||||
|
||||
## Unified workflow
|
||||
|
||||
Entry criteria:
|
||||
|
||||
- PR URL/number is known.
|
||||
- Problem statement is clear enough to attempt reproduction.
|
||||
- A realistic verification path exists (tests, integration checks, or explicit manual validation).
|
||||
|
||||
### 1) `review-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Review only: correctness, value, security risk, tests, docs, and changelog impact.
|
||||
- Produce structured findings and a recommendation.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Recommendation: ready, needs work, needs discussion, or close.
|
||||
- `.local/review.md` with actionable findings.
|
||||
|
||||
Maintainer checkpoint before `prepare-pr`:
|
||||
|
||||
```
|
||||
What problem are they trying to solve?
|
||||
What is the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Can we fix up everything?
|
||||
Do we have any questions?
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
|
||||
- The problem cannot be reproduced or confirmed.
|
||||
- The proposed PR scope does not match the stated problem.
|
||||
- The design introduces unresolved security or trust-boundary concerns.
|
||||
|
||||
### 2) `prepare-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Make the PR merge-ready on its head branch.
|
||||
- Rebase onto current `main`, fix blocker/important findings, and run gates.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Updated code and tests on the PR head branch.
|
||||
- `.local/prep.md` with changes, verification, and current HEAD SHA.
|
||||
- Final status: `PR is ready for /mergepr`.
|
||||
|
||||
Maintainer checkpoint before `merge-pr`:
|
||||
|
||||
```
|
||||
Is this the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Is the code properly typed?
|
||||
Is the code hardened?
|
||||
Do we have enough tests?
|
||||
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do not add performative tests, ensure tests are real and there are no regressions.
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
Do you see any follow-up refactors we should do?
|
||||
Did any changes introduce any potential security vulnerabilities?
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
|
||||
- You cannot verify behavior changes with meaningful tests or validation.
|
||||
- Fixing findings requires broad architecture changes outside safe PR scope.
|
||||
- Security hardening requirements remain unresolved.
|
||||
|
||||
### 3) `merge-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Merge only after review and prep artifacts are present and checks are green.
|
||||
- Use squash merge flow and verify the PR ends in `MERGED` state.
|
||||
|
||||
Go or no-go checklist before merge:
|
||||
|
||||
- All BLOCKER and IMPORTANT findings are resolved.
|
||||
- Verification is meaningful and regression risk is acceptably low.
|
||||
- Docs and changelog are updated when required.
|
||||
- Required CI checks are green and the branch is not behind `main`.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Successful merge commit and recorded merge SHA.
|
||||
- Worktree cleanup after successful merge.
|
||||
|
||||
Maintainer checkpoint after merge:
|
||||
|
||||
- Were any refactors intentionally deferred and now need follow-up issue(s)?
|
||||
- Did this reveal broader architecture or test gaps we should address?
|
||||
@@ -28,7 +28,7 @@ Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after s
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/Development/openclaw`, not `~/openclaw`.
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
|
||||
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
|
||||
- Expect cleanup to remove `.local/` artifacts.
|
||||
@@ -49,7 +49,7 @@ Create a checklist of all merge steps, print it, then continue and execute the c
|
||||
Use an isolated worktree for all merge work.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
@@ -104,6 +104,8 @@ Stop if any are true:
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
|
||||
If `.local/prep.md` contains `Docs-only change detected with high confidence; skipping pnpm test.`, that local test skip is allowed. CI checks still must be green.
|
||||
|
||||
```sh
|
||||
# Checks
|
||||
gh pr checks <PR>
|
||||
@@ -167,7 +169,7 @@ gh pr view <PR> --json state --jq .state
|
||||
Run cleanup only if step 6 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
cd ~/dev/openclaw
|
||||
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Prepare a PR branch for merge with review fixes, green gates, and an updated hea
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
@@ -38,7 +38,7 @@ Prepare a PR branch for merge with review fixes, green gates, and an updated hea
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Run gates and pass.
|
||||
- Run required gates and pass (docs-only PRs may skip `pnpm test` when high-confidence docs-only criteria are met and documented).
|
||||
- Commit prep changes.
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` with a prep summary.
|
||||
@@ -163,17 +163,46 @@ If `committer` is not found:
|
||||
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
|
||||
```
|
||||
|
||||
8. Run full gates before pushing
|
||||
8. Decide verification mode and run required gates before pushing
|
||||
|
||||
If you are highly confident the change is docs-only, you may skip `pnpm test`.
|
||||
|
||||
High-confidence docs-only criteria (all must be true):
|
||||
|
||||
- Every changed file is documentation-only (`docs/**`, `README*.md`, `CHANGELOG.md`, `*.md`, `*.mdx`, `mintlify.json`, `docs.json`).
|
||||
- No code, runtime, test, dependency, or build config files changed (`src/**`, `extensions/**`, `apps/**`, `package.json`, lockfiles, TS/JS config, test files, scripts).
|
||||
- `.local/review.md` does not call for non-doc behavior fixes.
|
||||
|
||||
Suggested check:
|
||||
|
||||
```sh
|
||||
changed_files=$(git diff --name-only origin/main...HEAD)
|
||||
non_docs=$(printf "%s\n" "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true)
|
||||
|
||||
docs_only=false
|
||||
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
|
||||
docs_only=true
|
||||
fi
|
||||
|
||||
echo "docs_only=$docs_only"
|
||||
```
|
||||
|
||||
Run required gates:
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm check
|
||||
pnpm test
|
||||
|
||||
if [ "$docs_only" = "true" ]; then
|
||||
echo "Docs-only change detected with high confidence; skipping pnpm test." | tee -a .local/prep.md
|
||||
else
|
||||
pnpm test
|
||||
fi
|
||||
```
|
||||
|
||||
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
Require all required gates to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
|
||||
9. Push updates back to the PR head branch
|
||||
|
||||
@@ -245,4 +274,4 @@ Otherwise, list remaining failures and stop.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all gates before pushing.
|
||||
- Run and pass all required gates before pushing. `pnpm test` may be skipped only for high-confidence docs-only changes, and the skip must be explicitly recorded in `.local/prep.md`.
|
||||
|
||||
@@ -28,7 +28,7 @@ Perform a thorough review-only PR assessment and return a structured recommendat
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/openclaw`.
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
@@ -51,7 +51,7 @@ Create a checklist of all review steps, print it, then continue and execute the
|
||||
Use an isolated worktree for all review work.
|
||||
|
||||
```sh
|
||||
cd ~/Development/openclaw
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
|
||||
41
.github/actions/detect-docs-only/action.yml
vendored
Normal file
41
.github/actions/detect-docs-only/action.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Detect docs-only changes
|
||||
description: >
|
||||
Outputs docs_only=true when all changed files are under docs/ or are
|
||||
markdown (.md/.mdx). Fail-safe: if detection fails, outputs false (run
|
||||
everything). Uses git diff — no API calls, no extra permissions needed.
|
||||
|
||||
outputs:
|
||||
docs_only:
|
||||
description: "'true' if all changes are docs/markdown, 'false' otherwise"
|
||||
value: ${{ steps.check.outputs.docs_only }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
# Use the exact base SHA from the event payload — stable regardless
|
||||
# of base branch movement (avoids origin/<ref> drift).
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
# Fail-safe: if we can't diff, assume non-docs (run everything)
|
||||
CHANGED=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
echo "docs_only=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
83
.github/actions/setup-node-env/action.yml
vendored
Normal file
83
.github/actions/setup-node-env/action.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
|
||||
and run pnpm install. Requires actions/checkout to run first.
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
required: false
|
||||
default: "22.x"
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
default: "true"
|
||||
frozen-lockfile:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Checkout submodules (retry)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
shell: bash
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
shell: bash
|
||||
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
env:
|
||||
CI: "true"
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
LOCKFILE_FLAG=""
|
||||
if [ "${{ inputs.frozen-lockfile }}" = "true" ]; then
|
||||
LOCKFILE_FLAG="--frozen-lockfile"
|
||||
fi
|
||||
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || \
|
||||
pnpm install $LOCKFILE_FLAG --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
41
.github/actions/setup-pnpm-store-cache/action.yml
vendored
Normal file
41
.github/actions/setup-pnpm-store-cache/action.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Setup pnpm + store cache
|
||||
description: Prepare pnpm via corepack and restore pnpm store cache.
|
||||
inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "10.23.0"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
default: "node22"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup pnpm (corepack retry)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare "pnpm@${{ inputs.pnpm-version }}" --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-
|
||||
64
.github/instructions/copilot.instructions.md
vendored
Normal file
64
.github/instructions/copilot.instructions.md
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
# OpenClaw Codebase Patterns
|
||||
|
||||
**Always reuse existing code - no redundancy!**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node 22+ (Bun also supported for dev/scripts)
|
||||
- **Language**: TypeScript (ESM, strict mode)
|
||||
- **Package Manager**: pnpm (keep `pnpm-lock.yaml` in sync)
|
||||
- **Lint/Format**: Oxlint, Oxfmt (`pnpm check`)
|
||||
- **Tests**: Vitest with V8 coverage
|
||||
- **CLI Framework**: Commander + clack/prompts
|
||||
- **Build**: tsdown (outputs to `dist/`)
|
||||
|
||||
## Anti-Redundancy Rules
|
||||
|
||||
- Avoid files that just re-export from another file. Import directly from the original source.
|
||||
- If a function already exists, import it - do NOT create a duplicate in another file.
|
||||
- Before creating any formatter, utility, or helper, search for existing implementations first.
|
||||
|
||||
## Source of Truth Locations
|
||||
|
||||
### Formatting Utilities (`src/infra/`)
|
||||
|
||||
- **Time formatting**: `src\infra\format-time`
|
||||
|
||||
**NEVER create local `formatAge`, `formatDuration`, `formatElapsedTime` functions - import from centralized modules.**
|
||||
|
||||
### Terminal Output (`src/terminal/`)
|
||||
|
||||
- Tables: `src/terminal/table.ts` (`renderTable`)
|
||||
- Themes/colors: `src/terminal/theme.ts` (`theme.success`, `theme.muted`, etc.)
|
||||
- Progress: `src/cli/progress.ts` (spinners, progress bars)
|
||||
|
||||
### CLI Patterns
|
||||
|
||||
- CLI option wiring: `src/cli/`
|
||||
- Commands: `src/commands/`
|
||||
- Dependency injection via `createDefaultDeps`
|
||||
|
||||
## Import Conventions
|
||||
|
||||
- Use `.js` extension for cross-package imports (ESM)
|
||||
- Direct imports only - no re-export wrapper files
|
||||
- Types: `import type { X }` for type-only imports
|
||||
|
||||
## Code Quality
|
||||
|
||||
- TypeScript (ESM), strict typing, avoid `any`
|
||||
- Keep files under ~700 LOC - extract helpers when larger
|
||||
- Colocated tests: `*.test.ts` next to source files
|
||||
- Run `pnpm check` before commits (lint + format)
|
||||
- Run `pnpm tsgo` for type checking
|
||||
|
||||
## Stack & Commands
|
||||
|
||||
- **Package manager**: pnpm (`pnpm install`)
|
||||
- **Dev**: `pnpm openclaw ...` or `pnpm dev`
|
||||
- **Type-check**: `pnpm tsgo`
|
||||
- **Lint/format**: `pnpm check`
|
||||
- **Tests**: `pnpm test`
|
||||
- **Build**: `pnpm build`
|
||||
|
||||
If you are coding together with a human, do NOT use scripts/committer, but git directly and run the above commands manually to ensure quality.
|
||||
553
.github/workflows/ci.yml
vendored
553
.github/workflows/ci.yml
vendored
@@ -2,10 +2,126 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install-check:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
docs-scope:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
|
||||
# Detect which heavy areas are touched so PRs can skip unrelated expensive jobs.
|
||||
# Push to main keeps broad coverage.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
run_android: ${{ steps.scope.outputs.run_android }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Detect changed scopes
|
||||
id: scope
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
# Fail-safe: run broad checks if detection fails.
|
||||
echo "run_node=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_node=false
|
||||
run_macos=false
|
||||
run_android=false
|
||||
has_non_docs=false
|
||||
has_non_native_non_docs=false
|
||||
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
case "$path" in
|
||||
docs/*|*.md|*.mdx)
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
has_non_docs=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
|
||||
run_macos=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/shared/*)
|
||||
run_android=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
|
||||
run_node=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
|
||||
;;
|
||||
*)
|
||||
has_non_native_non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
# If there are non-doc files outside native app trees, keep Node checks enabled.
|
||||
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
|
||||
run_node=true
|
||||
fi
|
||||
|
||||
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope, code-size, check-lint]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -13,59 +129,39 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
install-bun: "false"
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
install-check:
|
||||
needs: [docs-scope, changed-scope, code-size, check-lint]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Install dependencies (frozen)
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope, code-size, check-lint]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -74,88 +170,92 @@ jobs:
|
||||
- runtime: node
|
||||
task: tsgo
|
||||
command: pnpm tsgo
|
||||
- runtime: node
|
||||
task: lint
|
||||
command: pnpm build && pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
- runtime: node
|
||||
task: format
|
||||
command: pnpm format
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
bun -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
# Format check — cheapest gate (~43s). Always runs, even on docs-only changes.
|
||||
check-format:
|
||||
name: "check: format"
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format
|
||||
|
||||
# Lint check — runs after format passes for cleaner output.
|
||||
check-lint:
|
||||
name: "check: lint"
|
||||
needs: [check-format]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
|
||||
- name: Check lint
|
||||
run: pnpm lint
|
||||
|
||||
# Check for files that grew past LOC threshold in this PR (delta-only).
|
||||
# On push events, all steps are skipped and the job passes (no-op).
|
||||
# Heavy downstream jobs depend on this to fail fast on violations.
|
||||
code-size:
|
||||
needs: [check-format]
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Fetch base branch
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}
|
||||
|
||||
- name: Check code file sizes
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
python scripts/analyze_code_files.py \
|
||||
--compare-to origin/${{ github.base_ref }} \
|
||||
--threshold 1000 \
|
||||
--strict
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -182,10 +282,14 @@ jobs:
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, code-size, check-lint]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
CLAWDBOT_TEST_WORKERS: 1
|
||||
# Keep total concurrency predictable on the 4 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -194,8 +298,8 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: build & lint
|
||||
command: pnpm build && pnpm lint
|
||||
task: lint
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
@@ -208,6 +312,25 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
||||
# (eg hardened images), keep going and rely on worker limiting above.
|
||||
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
||||
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
||||
Write-Host "Defender exclusions applied."
|
||||
} catch {
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -221,25 +344,31 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Download dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Verify dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/index.js
|
||||
test -s dist/plugin-sdk/index.js
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -269,138 +398,39 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
|
||||
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
needs: [docs-scope, changed-scope, code-size, check-lint]
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
install-bun: "false"
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
npm -v
|
||||
pnpm -v
|
||||
|
||||
- name: Capture node path
|
||||
run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
export PATH="$NODE_BIN:$PATH"
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
run: pnpm test
|
||||
|
||||
# --- Xcode/Swift setup ---
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
@@ -408,8 +438,43 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
- name: Swift lint
|
||||
run: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-swiftpm-
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
@@ -584,6 +649,8 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope, code-size, check-lint]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
11
.github/workflows/docker-release.yml
vendored
11
.github/workflows/docker-release.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "*.md"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
@@ -56,8 +59,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
@@ -105,8 +108,8 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
|
||||
4
.github/workflows/formal-conformance.yml
vendored
4
.github/workflows/formal-conformance.yml
vendored
@@ -3,6 +3,10 @@ name: Formal models (informational conformance)
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
formal_conformance:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
20
.github/workflows/install-smoke.yml
vendored
20
.github/workflows/install-smoke.yml
vendored
@@ -6,8 +6,28 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
uses: ./.github/actions/detect-docs-only
|
||||
|
||||
install-smoke:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
|
||||
5
.github/workflows/workflow-sanity.yml
vendored
5
.github/workflows/workflow-sanity.yml
vendored
@@ -3,6 +3,11 @@ name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -3,11 +3,13 @@ node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
@@ -16,6 +18,11 @@ ui/src/ui/__screenshots__/
|
||||
ui/playwright-report/
|
||||
ui/test-results/
|
||||
|
||||
# Android build artifacts
|
||||
apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
@@ -52,7 +59,6 @@ apps/ios/fastlane/screenshots/
|
||||
apps/ios/fastlane/test_output/
|
||||
apps/ios/fastlane/logs/
|
||||
apps/ios/fastlane/.env
|
||||
apps/ios/fastlane/report.xml
|
||||
|
||||
# fastlane build artifacts (local)
|
||||
apps/ios/*.ipa
|
||||
@@ -60,7 +66,6 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
@@ -72,6 +77,7 @@ USER.md
|
||||
.serena/
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
memory/
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
local/
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
|
||||
@@ -134,6 +134,7 @@
|
||||
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`).
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@@ -2,6 +2,56 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.9
|
||||
|
||||
### Added
|
||||
|
||||
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
|
||||
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
|
||||
- Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky.
|
||||
- Tools: add Grok (xAI) as a `web_search` provider. (#12419) Thanks @tmchow.
|
||||
- Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal.
|
||||
- Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.
|
||||
- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.
|
||||
- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.
|
||||
|
||||
### Fixes
|
||||
|
||||
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
|
||||
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
|
||||
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
|
||||
- Telegram: render markdown spoilers with `<tg-spoiler>` HTML tags. (#11543) Thanks @ezhikkk.
|
||||
- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale.
|
||||
- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
|
||||
- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).
|
||||
- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.
|
||||
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
|
||||
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
|
||||
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
|
||||
- Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.
|
||||
- Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.
|
||||
- Config: re-hydrate state-dir `.env` during runtime config loads so `${VAR}` substitutions remain resolvable. (#12748) Thanks @rodrigouroz.
|
||||
- Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman.
|
||||
- Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.
|
||||
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
|
||||
- Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.
|
||||
- Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.
|
||||
- Cron tool: recover flat params when LLM omits the `job` wrapper for add requests. (#12124) Thanks @tyler6204.
|
||||
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
|
||||
- CLI: make `openclaw plugins list` output scannable by hoisting source roots and shortening bundled/global/workspace plugin paths.
|
||||
- Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.
|
||||
- Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.
|
||||
- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.
|
||||
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
|
||||
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
|
||||
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
|
||||
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
|
||||
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
@@ -10,10 +60,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
|
||||
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
|
||||
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
|
||||
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
|
||||
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
|
||||
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
|
||||
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
|
||||
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
|
||||
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
|
||||
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -44,5 +44,5 @@ USER node
|
||||
#
|
||||
# For container platforms requiring external health checks:
|
||||
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
|
||||
# 2. Override CMD: ["node","dist/index.js","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "dist/index.js", "gateway", "--allow-unconfigured"]
|
||||
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
|
||||
@@ -13,4 +13,8 @@ RUN apt-get update \
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
CMD ["sleep", "infinity"]
|
||||
|
||||
@@ -23,6 +23,10 @@ RUN apt-get update \
|
||||
COPY scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
||||
RUN chmod +x /usr/local/bin/openclaw-sandbox-browser
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
EXPOSE 9222 5900 6080
|
||||
|
||||
CMD ["openclaw-sandbox-browser"]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -23,9 +23,10 @@ It answers you on the channels you already use (WhatsApp, Telegram, Slack, Disco
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-clawdbot) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/start/faq) · [Wizard](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`). It walks through gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Preferred setup: run the onboarding wizard (`openclaw onboard`) in your terminal.
|
||||
The wizard guides you step by step through setting up the gateway, workspace, channels, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
|
||||
@@ -4,8 +4,13 @@ If you believe you've found a security issue in OpenClaw, please report it priva
|
||||
|
||||
## Reporting
|
||||
|
||||
- Email: `steipete@gmail.com`
|
||||
- What to include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
||||
For full reporting instructions - including which repo to report to and how - see our [Trust page](https://trust.openclaw.ai).
|
||||
|
||||
Include: reproduction steps, impact assessment, and (if possible) a minimal PoC.
|
||||
|
||||
## Security & Trust
|
||||
|
||||
**Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) is Security & Trust at OpenClaw. Jamieson is the founder of [Dvuln](https://dvuln.com) and brings extensive experience in offensive security, penetration testing, and security program development.
|
||||
|
||||
## Bug Bounties
|
||||
|
||||
|
||||
124
appcast.xml
124
appcast.xml
@@ -2,6 +2,62 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.9</title>
|
||||
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9194</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.9</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.9</h2>
|
||||
<h3>Added</h3>
|
||||
<ul>
|
||||
<li>iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.</li>
|
||||
<li>Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.</li>
|
||||
<li>Plugins: device pairing + phone control plugins (Telegram <code>/pair</code>, iOS/Android node controls). (#11755) Thanks @mbelinky.</li>
|
||||
<li>Tools: add Grok (xAI) as a <code>web_search</code> provider. (#12419) Thanks @tmchow.</li>
|
||||
<li>Gateway: add agent management RPC methods for the web UI (<code>agents.create</code>, <code>agents.update</code>, <code>agents.delete</code>). (#11045) Thanks @advaitpaliwal.</li>
|
||||
<li>Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.</li>
|
||||
<li>Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.</li>
|
||||
<li>Paths: add <code>OPENCLAW_HOME</code> for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.</li>
|
||||
<li>Telegram: recover proactive sends when stale topic thread IDs are used by retrying without <code>message_thread_id</code>. (#11620)</li>
|
||||
<li>Telegram: render markdown spoilers with <code><tg-spoiler></code> HTML tags. (#11543) Thanks @ezhikkk.</li>
|
||||
<li>Telegram: truncate command registration to 100 entries to avoid <code>BOT_COMMANDS_TOO_MUCH</code> failures on startup. (#12356) Thanks @arosstale.</li>
|
||||
<li>Telegram: match DM <code>allowFrom</code> against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).</li>
|
||||
<li>Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.</li>
|
||||
<li>Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.</li>
|
||||
<li>Tools/web_search: include provider-specific settings in the web search cache key, and pass <code>inlineCitations</code> for Grok. (#12419) Thanks @tmchow.</li>
|
||||
<li>Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.</li>
|
||||
<li>Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.</li>
|
||||
<li>Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.</li>
|
||||
<li>Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session <code>parentId</code> chain so agents can remember again. (#12283) Thanks @Takhoffman.</li>
|
||||
<li>Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.</li>
|
||||
<li>Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.</li>
|
||||
<li>Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.</li>
|
||||
<li>Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.</li>
|
||||
<li>Cron tool: recover flat params when LLM omits the <code>job</code> wrapper for add requests. (#12124) Thanks @tyler6204.</li>
|
||||
<li>Gateway/CLI: when <code>gateway.bind=lan</code>, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.</li>
|
||||
<li>Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.</li>
|
||||
<li>Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.</li>
|
||||
<li>Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.</li>
|
||||
<li>Config: clamp <code>maxTokens</code> to <code>contextWindow</code> to prevent invalid model configs. (#5516) Thanks @lailoo.</li>
|
||||
<li>Thinking: allow xhigh for <code>github-copilot/gpt-5.2-codex</code> and <code>github-copilot/gpt-5.2</code>. (#11646) Thanks @LatencyTDH.</li>
|
||||
<li>Discord: support forum/media thread-create starter messages, wire <code>message thread create --message</code>, and harden routing. (#10062) Thanks @jarvis89757.</li>
|
||||
<li>Paths: structurally resolve <code>OPENCLAW_HOME</code>-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.</li>
|
||||
<li>Memory: set Voyage embeddings <code>input_type</code> for improved retrieval. (#10818) Thanks @mcinteerj.</li>
|
||||
<li>Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.</li>
|
||||
<li>Media understanding: recognize <code>.caf</code> audio attachments for transcription. (#10982) Thanks @succ985.</li>
|
||||
<li>State dir: honor <code>OPENCLAW_STATE_DIR</code> for default device identity and canvas storage paths. (#4824) Thanks @kossoy.</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.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
@@ -96,71 +152,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.2/OpenClaw-2026.2.2.zip" length="22519052" type="application/octet-stream" sparkle:edSignature="a6viD+aS5EfY/RkPIPMfoQQNkJCk6QTdV5WobXFxyYwURskUm8/nXTHVXsCh1c5+0WKUnmlDIyf0i+6IWiavAA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.1</title>
|
||||
<pubDate>Mon, 02 Feb 2026 03:53:03 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8650</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.1</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)</li>
|
||||
<li>Telegram: use shared pairing store. (#6127) Thanks @obviyus.</li>
|
||||
<li>Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.</li>
|
||||
<li>Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.</li>
|
||||
<li>Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).</li>
|
||||
<li>Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.</li>
|
||||
<li>Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)</li>
|
||||
<li>Auth: update MiniMax OAuth hint + portal auth note copy.</li>
|
||||
<li>Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.</li>
|
||||
<li>Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.</li>
|
||||
<li>Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.</li>
|
||||
<li>Web UI: refine chat layout + extend session active duration.</li>
|
||||
<li>CI: add formal conformance + alias consistency checks. (#5723, #5807)</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Plugins: validate plugin/hook install paths and reject traversal-like names.</li>
|
||||
<li>Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.</li>
|
||||
<li>Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.</li>
|
||||
<li>Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)</li>
|
||||
<li>Streaming: stabilize partial streaming filters.</li>
|
||||
<li>Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.</li>
|
||||
<li>Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).</li>
|
||||
<li>Tools: treat <code>"*"</code> tool allowlist entries as valid to avoid spurious unknown-entry warnings.</li>
|
||||
<li>Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)</li>
|
||||
<li>Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.</li>
|
||||
<li>Lint: satisfy curly rule after import sorting. (#6310)</li>
|
||||
<li>Process: resolve Windows <code>spawn()</code> failures for npm-family CLIs by appending <code>.cmd</code> when needed. (#5815) Thanks @thejhinvirtuoso.</li>
|
||||
<li>Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.</li>
|
||||
<li>Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)</li>
|
||||
<li>Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)</li>
|
||||
<li>Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).</li>
|
||||
<li>Agents: ensure OpenRouter attribution headers apply in the embedded runner.</li>
|
||||
<li>Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.</li>
|
||||
<li>System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)</li>
|
||||
<li>Agents: fix Pi prompt template argument syntax. (#6543)</li>
|
||||
<li>Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)</li>
|
||||
<li>Teams: gate media auth retries.</li>
|
||||
<li>Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.</li>
|
||||
<li>Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.</li>
|
||||
<li>TUI: prevent crash when searching with digits in the model selector.</li>
|
||||
<li>Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.</li>
|
||||
<li>Browser: secure Chrome extension relay CDP sessions.</li>
|
||||
<li>Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.</li>
|
||||
<li>fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.</li>
|
||||
<li>Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)</li>
|
||||
<li>Security: restrict MEDIA path extraction to prevent LFI. (#4930)</li>
|
||||
<li>Security: validate message-tool filePath/path against sandbox root. (#6398)</li>
|
||||
<li>Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.</li>
|
||||
<li>Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.</li>
|
||||
<li>Security: enforce Twitch <code>allowFrom</code> allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.</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.2.1/OpenClaw-2026.2.1.zip" length="22458919" type="application/octet-stream" sparkle:edSignature="kA/8VQlVdtYphcB1iuFrhWczwWKgkVZMfDfQ7T9WD405D8JKTv5CZ1n8lstIVkpk4xog3UhrfaaoTG8Bf8DMAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -22,7 +22,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602030
|
||||
versionName = "2026.2.6"
|
||||
versionName = "2026.2.9"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
58
apps/config-builder/README.md
Normal file
58
apps/config-builder/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Config Builder (WIP)
|
||||
|
||||
This workspace package will host the standalone OpenClaw config builder app.
|
||||
|
||||
## Stack
|
||||
|
||||
Use the same front-end stack as the existing OpenClaw web UI (`ui/`):
|
||||
|
||||
- Vite
|
||||
- Lit
|
||||
- Plain CSS (no Next.js/Tailwind)
|
||||
|
||||
## Current status
|
||||
|
||||
Phase 0 through Phase 6 are implemented:
|
||||
|
||||
- app boots with Vite + Lit
|
||||
- `OpenClawSchema.toJSONSchema()` runs in browser bundle
|
||||
- `buildConfigSchema()` UI hints load in browser bundle
|
||||
- Explorer mode supports grouped schema editing + search/filter
|
||||
- Typed field renderer covers:
|
||||
- strings, numbers, integers, booleans, enums
|
||||
- primitive arrays with add/remove
|
||||
- record-like objects (key/value editor)
|
||||
- JSON fallback editor for complex array/object shapes
|
||||
- Validation + error UX:
|
||||
- real-time `OpenClawSchema` validation
|
||||
- inline field-level errors
|
||||
- section-level error counts + global summary
|
||||
- Wizard mode:
|
||||
- 7 curated steps with progress indicators
|
||||
- back/continue flow with shared renderer/state
|
||||
- JSON5 preview panel:
|
||||
- sparse output
|
||||
- copy/download/reset
|
||||
- sensitive-value warning banner
|
||||
- Routing + polish:
|
||||
- landing page + mode routing via hash (`#/`, `#/explorer`, `#/wizard`)
|
||||
- responsive layout including mobile preview drawer behavior
|
||||
- docs link in topbar
|
||||
- Vercel static config (`apps/config-builder/vercel.json`)
|
||||
|
||||
To run locally:
|
||||
|
||||
```bash
|
||||
pnpm --filter @openclaw/config-builder dev
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
Implementation details are tracked in `.local/config-builder-spec.md`.
|
||||
|
||||
For the spike, Vite aliases lightweight browser shims for:
|
||||
|
||||
- `src/version.ts`
|
||||
- `src/channels/registry.ts`
|
||||
|
||||
This keeps schema imports browser-safe while preserving the existing Node runtime modules.
|
||||
12
apps/config-builder/index.html
Normal file
12
apps/config-builder/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OpenClaw Config Builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<config-builder-app></config-builder-app>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
apps/config-builder/package.json
Normal file
20
apps/config-builder/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@openclaw/config-builder",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run --config vitest.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": "^2.2.3",
|
||||
"lit": "^3.3.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "7.3.1",
|
||||
"vitest": "4.0.18"
|
||||
}
|
||||
}
|
||||
18
apps/config-builder/src/lib/config-store.test.ts
Normal file
18
apps/config-builder/src/lib/config-store.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clearFieldValue, getFieldValue, setFieldValue } from "./config-store.ts";
|
||||
|
||||
describe("config-store helpers", () => {
|
||||
it("sets and reads nested fields", () => {
|
||||
const next = setFieldValue({}, "gateway.auth.token", "abc123");
|
||||
expect(getFieldValue(next, "gateway.auth.token")).toBe("abc123");
|
||||
expect(next.gateway).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clears nested fields and prunes empty parents", () => {
|
||||
const seeded = setFieldValue({}, "gateway.auth.token", "abc123");
|
||||
const cleared = clearFieldValue(seeded, "gateway.auth.token");
|
||||
|
||||
expect(getFieldValue(cleared, "gateway.auth.token")).toBeUndefined();
|
||||
expect(cleared.gateway).toBeUndefined();
|
||||
});
|
||||
});
|
||||
170
apps/config-builder/src/lib/config-store.ts
Normal file
170
apps/config-builder/src/lib/config-store.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export type ConfigDraft = Record<string, unknown>;
|
||||
|
||||
const STORAGE_KEY = "openclaw.config-builder.v1";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function cloneDraft(input: ConfigDraft): ConfigDraft {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(input);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(input)) as ConfigDraft;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string[] {
|
||||
return path
|
||||
.split(".")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function pruneEmptyObjects(value: unknown): unknown {
|
||||
if (!isRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
const cleaned = pruneEmptyObjects(nested);
|
||||
if (cleaned === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (isRecord(cleaned) && Object.keys(cleaned).length === 0) {
|
||||
continue;
|
||||
}
|
||||
next[key] = cleaned;
|
||||
}
|
||||
return Object.keys(next).length === 0 ? undefined : next;
|
||||
}
|
||||
|
||||
export function getFieldValue(config: ConfigDraft, path: string): unknown {
|
||||
const segments = normalizePath(path);
|
||||
let current: unknown = config;
|
||||
for (const segment of segments) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setFieldValue(config: ConfigDraft, path: string, value: unknown): ConfigDraft {
|
||||
const segments = normalizePath(path);
|
||||
if (segments.length === 0) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const next = cloneDraft(config);
|
||||
let cursor: Record<string, unknown> = next;
|
||||
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
const segment = segments[index];
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
const existing = cursor[segment];
|
||||
if (isRecord(existing)) {
|
||||
cursor = existing;
|
||||
continue;
|
||||
}
|
||||
const child: Record<string, unknown> = {};
|
||||
cursor[segment] = child;
|
||||
cursor = child;
|
||||
}
|
||||
|
||||
const leaf = segments.at(-1);
|
||||
if (!leaf) {
|
||||
return next;
|
||||
}
|
||||
|
||||
cursor[leaf] = value;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function clearFieldValue(config: ConfigDraft, path: string): ConfigDraft {
|
||||
const segments = normalizePath(path);
|
||||
if (segments.length === 0) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const next = cloneDraft(config);
|
||||
const parents: Array<Record<string, unknown>> = [];
|
||||
let cursor: unknown = next;
|
||||
|
||||
for (let index = 0; index < segments.length - 1; index += 1) {
|
||||
if (!isRecord(cursor)) {
|
||||
return next;
|
||||
}
|
||||
const segment = segments[index];
|
||||
if (!segment) {
|
||||
return next;
|
||||
}
|
||||
parents.push(cursor);
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
|
||||
if (!isRecord(cursor)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const leaf = segments.at(-1);
|
||||
if (!leaf) {
|
||||
return next;
|
||||
}
|
||||
|
||||
delete cursor[leaf];
|
||||
|
||||
for (let index = segments.length - 2; index >= 0; index -= 1) {
|
||||
const parent = parents[index];
|
||||
const key = segments[index];
|
||||
if (!parent || !key) {
|
||||
continue;
|
||||
}
|
||||
const child = parent[key];
|
||||
if (!isRecord(child)) {
|
||||
continue;
|
||||
}
|
||||
if (Object.keys(child).length === 0) {
|
||||
delete parent[key];
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = pruneEmptyObjects(next);
|
||||
return isRecord(cleaned) ? cleaned : {};
|
||||
}
|
||||
|
||||
export function resetDraft(): ConfigDraft {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function loadPersistedDraft(storage: Storage | null = globalThis.localStorage ?? null): ConfigDraft {
|
||||
if (!storage) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function persistDraft(
|
||||
config: ConfigDraft,
|
||||
storage: Storage | null = globalThis.localStorage ?? null,
|
||||
): void {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
// best-effort persistence only
|
||||
}
|
||||
}
|
||||
12
apps/config-builder/src/lib/json5-format.test.ts
Normal file
12
apps/config-builder/src/lib/json5-format.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatConfigJson5 } from "./json5-format.ts";
|
||||
|
||||
describe("formatConfigJson5", () => {
|
||||
it("formats sparse config and computes size metadata", () => {
|
||||
const preview = formatConfigJson5({ gateway: { port: 18789 } });
|
||||
expect(preview.text).toContain("gateway");
|
||||
expect(preview.text).toContain("18789");
|
||||
expect(preview.lineCount).toBeGreaterThan(0);
|
||||
expect(preview.byteCount).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
34
apps/config-builder/src/lib/json5-format.ts
Normal file
34
apps/config-builder/src/lib/json5-format.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import JSON5 from "json5";
|
||||
import type { ConfigDraft } from "./config-store.ts";
|
||||
|
||||
export type Json5Preview = {
|
||||
text: string;
|
||||
lineCount: number;
|
||||
byteCount: number;
|
||||
};
|
||||
|
||||
export function formatConfigJson5(config: ConfigDraft): Json5Preview {
|
||||
const text = `${JSON5.stringify(config, null, 2)}\n`;
|
||||
const lineCount = text.split(/\r?\n/).length - 1;
|
||||
const byteCount = new TextEncoder().encode(text).byteLength;
|
||||
return {
|
||||
text,
|
||||
lineCount,
|
||||
byteCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadJson5File(text: string, filename = "openclaw.json"): void {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([text], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
72
apps/config-builder/src/lib/schema-spike.test.ts
Normal file
72
apps/config-builder/src/lib/schema-spike.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildExplorerSnapshot, resolveExplorerField } from "./schema-spike.ts";
|
||||
|
||||
describe("buildExplorerSnapshot", () => {
|
||||
it("builds ordered sections and field metadata", () => {
|
||||
const snapshot = buildExplorerSnapshot();
|
||||
|
||||
expect(snapshot.sectionCount).toBeGreaterThan(0);
|
||||
expect(snapshot.fieldCount).toBeGreaterThan(0);
|
||||
expect(snapshot.sections[0]?.order).toBeLessThanOrEqual(snapshot.sections.at(-1)?.order ?? 0);
|
||||
|
||||
const gatewaySection = snapshot.sections.find((section) => section.id === "gateway");
|
||||
expect(gatewaySection).toBeTruthy();
|
||||
expect(gatewaySection?.fields.some((field) => field.path === "gateway.auth.token")).toBe(true);
|
||||
|
||||
const tokenField = gatewaySection?.fields.find((field) => field.path === "gateway.auth.token");
|
||||
expect(tokenField?.sensitive).toBe(true);
|
||||
expect(tokenField?.kind).toBe("string");
|
||||
expect(tokenField?.editable).toBe(true);
|
||||
|
||||
const wildcardField = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path.includes("*"));
|
||||
expect(wildcardField?.editable).toBe(false);
|
||||
|
||||
const telegramToken = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === "channels.telegram.botToken");
|
||||
expect(telegramToken?.kind).toBe("string");
|
||||
|
||||
const updateChannel = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === "update.channel");
|
||||
expect(updateChannel?.kind).toBe("enum");
|
||||
expect(updateChannel?.enumValues).toContain("stable");
|
||||
|
||||
const arrayField = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === "tools.alsoAllow");
|
||||
expect(arrayField?.kind).toBe("array");
|
||||
expect(arrayField?.itemKind).toBe("string");
|
||||
|
||||
const recordField = snapshot.sections
|
||||
.flatMap((section) => section.fields)
|
||||
.find((field) => field.path === "diagnostics.otel.headers");
|
||||
expect(recordField?.kind).toBe("object");
|
||||
expect(recordField?.recordValueKind).toBe("string");
|
||||
|
||||
const browserFields = snapshot.sections
|
||||
.find((section) => section.id === "browser")
|
||||
?.fields.map((field) => field.path) ?? [];
|
||||
expect(browserFields.includes("browser.snapshotDefaults.mode")).toBe(true);
|
||||
expect(browserFields.includes("browser.snapshotDefaults")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveExplorerField", () => {
|
||||
it("resolves metadata for paths that do not have explicit UI hints", () => {
|
||||
const port = resolveExplorerField("gateway.port");
|
||||
expect(port).toBeTruthy();
|
||||
expect(port?.kind).toBe("integer");
|
||||
expect(port?.editable).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for unknown paths", () => {
|
||||
expect(resolveExplorerField("this.path.does.not.exist")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops hint-only paths that are not in the schema", () => {
|
||||
expect(resolveExplorerField("tools.web.fetch.firecrawl.enabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
517
apps/config-builder/src/lib/schema-spike.ts
Normal file
517
apps/config-builder/src/lib/schema-spike.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
import { buildConfigSchema, type ConfigUiHint, type ConfigUiHints } from "@openclaw/config/schema.ts";
|
||||
import { OpenClawSchema } from "@openclaw/config/zod-schema.ts";
|
||||
|
||||
type JsonSchemaNode = {
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
const?: unknown;
|
||||
type?: string | string[];
|
||||
enum?: unknown[];
|
||||
properties?: Record<string, JsonSchemaNode>;
|
||||
items?: JsonSchemaNode | JsonSchemaNode[];
|
||||
additionalProperties?: JsonSchemaNode | boolean;
|
||||
anyOf?: JsonSchemaNode[];
|
||||
oneOf?: JsonSchemaNode[];
|
||||
allOf?: JsonSchemaNode[];
|
||||
};
|
||||
|
||||
type SchemaContext = {
|
||||
schemaRoot: JsonSchemaNode;
|
||||
uiHints: ConfigUiHints;
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type FieldKind =
|
||||
| "string"
|
||||
| "number"
|
||||
| "integer"
|
||||
| "boolean"
|
||||
| "enum"
|
||||
| "array"
|
||||
| "object"
|
||||
| "unknown";
|
||||
|
||||
export type ExplorerSchemaNode = {
|
||||
kind: FieldKind;
|
||||
enumValues: string[];
|
||||
properties: Record<string, ExplorerSchemaNode>;
|
||||
item: ExplorerSchemaNode | null;
|
||||
additionalProperties: ExplorerSchemaNode | null;
|
||||
allowsUnknownProperties: boolean;
|
||||
};
|
||||
|
||||
export type ExplorerField = {
|
||||
path: string;
|
||||
label: string;
|
||||
help: string;
|
||||
sensitive: boolean;
|
||||
advanced: boolean;
|
||||
kind: FieldKind;
|
||||
enumValues: string[];
|
||||
itemKind: FieldKind | null;
|
||||
itemEnumValues: string[];
|
||||
recordValueKind: FieldKind | null;
|
||||
recordEnumValues: string[];
|
||||
hasDefault: boolean;
|
||||
editable: boolean;
|
||||
schemaNode: ExplorerSchemaNode | null;
|
||||
};
|
||||
|
||||
export type ExplorerSection = {
|
||||
id: string;
|
||||
label: string;
|
||||
order: number;
|
||||
description: string;
|
||||
fields: ExplorerField[];
|
||||
};
|
||||
|
||||
export type ExplorerSnapshot = {
|
||||
version: string;
|
||||
generatedAt: string;
|
||||
sectionCount: number;
|
||||
fieldCount: number;
|
||||
sections: ExplorerSection[];
|
||||
};
|
||||
|
||||
const SECTION_FALLBACK_ORDER = 500;
|
||||
|
||||
let cachedContext: SchemaContext | null = null;
|
||||
|
||||
function getSchemaContext(): SchemaContext {
|
||||
if (cachedContext) {
|
||||
return cachedContext;
|
||||
}
|
||||
|
||||
// buildConfigSchema() intentionally strips core channel schema from the base response.
|
||||
// For the standalone builder we want the complete core schema for interactive controls,
|
||||
// while still reusing uiHints/version metadata from buildConfigSchema().
|
||||
const configSchema = buildConfigSchema();
|
||||
const fullSchema = OpenClawSchema.toJSONSchema({
|
||||
target: "draft-07",
|
||||
unrepresentable: "any",
|
||||
});
|
||||
const schemaRoot = asObjectNode(fullSchema) ?? {};
|
||||
|
||||
cachedContext = {
|
||||
schemaRoot,
|
||||
uiHints: configSchema.uiHints,
|
||||
version: configSchema.version,
|
||||
generatedAt: configSchema.generatedAt,
|
||||
};
|
||||
return cachedContext;
|
||||
}
|
||||
|
||||
function humanizeKey(value: string): string {
|
||||
if (!value.trim()) {
|
||||
return value;
|
||||
}
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^./, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function firstPathSegment(path: string): string {
|
||||
const [segment] = path.split(".");
|
||||
return segment?.trim() ?? "";
|
||||
}
|
||||
|
||||
function lastPathSegment(path: string): string {
|
||||
const segments = path.split(".");
|
||||
return segments.at(-1) ?? path;
|
||||
}
|
||||
|
||||
function isSectionHint(path: string, hint: ConfigUiHint): boolean {
|
||||
return !path.includes(".") && typeof hint.order === "number" && typeof hint.group === "string";
|
||||
}
|
||||
|
||||
function fieldSort(a: ExplorerField, b: ExplorerField): number {
|
||||
return a.path.localeCompare(b.path);
|
||||
}
|
||||
|
||||
function sectionSort(a: ExplorerSection, b: ExplorerSection): number {
|
||||
if (a.order !== b.order) {
|
||||
return a.order - b.order;
|
||||
}
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
function pruneRedundantCompositeFields(fields: ExplorerField[]): ExplorerField[] {
|
||||
return fields.filter((field) => {
|
||||
if (field.kind !== "object" && field.kind !== "array") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we already expose concrete descendants as first-class fields,
|
||||
// do not also render the composite parent card (it duplicates controls).
|
||||
const prefix = `${field.path}.`;
|
||||
const hasDescendant = fields.some((candidate) =>
|
||||
candidate.path !== field.path && candidate.path.startsWith(prefix)
|
||||
);
|
||||
|
||||
return !hasDescendant;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSchemaPath(path: string): string[] {
|
||||
return path
|
||||
.replace(/\[\]/g, ".*")
|
||||
.split(".")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function asObjectNode(node: unknown): JsonSchemaNode | null {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) {
|
||||
return null;
|
||||
}
|
||||
return node as JsonSchemaNode;
|
||||
}
|
||||
|
||||
function resolveUnion(node: JsonSchemaNode): JsonSchemaNode {
|
||||
const pool = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])];
|
||||
const preferred = pool.find((entry) => {
|
||||
const type = entry.type;
|
||||
if (typeof type === "string") {
|
||||
return type !== "null";
|
||||
}
|
||||
if (Array.isArray(type)) {
|
||||
return type.some((part) => part !== "null");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return preferred ?? node;
|
||||
}
|
||||
|
||||
function resolveSchemaNode(root: JsonSchemaNode, path: string): JsonSchemaNode | null {
|
||||
const segments = normalizeSchemaPath(path);
|
||||
let current: JsonSchemaNode | null = root;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
current = resolveUnion(current);
|
||||
|
||||
if (segment === "*") {
|
||||
if (Array.isArray(current.items)) {
|
||||
current = current.items[0] ?? null;
|
||||
continue;
|
||||
}
|
||||
const itemNode = asObjectNode(current.items);
|
||||
if (itemNode) {
|
||||
current = itemNode;
|
||||
continue;
|
||||
}
|
||||
const additionalNode = asObjectNode(current.additionalProperties);
|
||||
if (additionalNode) {
|
||||
current = additionalNode;
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const properties = current.properties ?? {};
|
||||
if (segment in properties) {
|
||||
current = properties[segment] ?? null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const additionalNode = asObjectNode(current.additionalProperties);
|
||||
if (additionalNode) {
|
||||
current = additionalNode;
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function enumValuesFromNode(node: JsonSchemaNode | null, depth = 0): string[] {
|
||||
if (!node || depth > 5) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = new Set<string>();
|
||||
|
||||
if (Array.isArray(node.enum)) {
|
||||
for (const entry of node.enum) {
|
||||
values.add(String(entry));
|
||||
}
|
||||
}
|
||||
|
||||
if (node.const !== undefined) {
|
||||
values.add(String(node.const));
|
||||
}
|
||||
|
||||
const unionPool = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])];
|
||||
for (const entry of unionPool) {
|
||||
for (const option of enumValuesFromNode(entry, depth + 1)) {
|
||||
values.add(option);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(values);
|
||||
}
|
||||
|
||||
function hasOpenScalarType(node: JsonSchemaNode | null, expected: "string" | "number" | "integer" | "boolean", depth = 0): boolean {
|
||||
if (!node || depth > 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rawType = node.type;
|
||||
const matchesType =
|
||||
rawType === expected || (Array.isArray(rawType) && rawType.includes(expected));
|
||||
if (matchesType && node.const === undefined && !Array.isArray(node.enum)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const unionPool = [...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? [])];
|
||||
return unionPool.some((entry) => hasOpenScalarType(entry, expected, depth + 1));
|
||||
}
|
||||
|
||||
function resolveType(node: JsonSchemaNode | null): FieldKind {
|
||||
if (!node) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const enumValues = enumValuesFromNode(node);
|
||||
if (enumValues.length > 0) {
|
||||
if (hasOpenScalarType(node, "string")) {
|
||||
return "string";
|
||||
}
|
||||
if (hasOpenScalarType(node, "integer")) {
|
||||
return "integer";
|
||||
}
|
||||
if (hasOpenScalarType(node, "number")) {
|
||||
return "number";
|
||||
}
|
||||
if (hasOpenScalarType(node, "boolean")) {
|
||||
return "boolean";
|
||||
}
|
||||
return "enum";
|
||||
}
|
||||
|
||||
const resolved = resolveUnion(node);
|
||||
const rawType = resolved.type;
|
||||
const type = Array.isArray(rawType) ? rawType.find((entry) => entry !== "null") : rawType;
|
||||
|
||||
switch (type) {
|
||||
case "string":
|
||||
return "string";
|
||||
case "number":
|
||||
return "number";
|
||||
case "integer":
|
||||
return "integer";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "array":
|
||||
return "array";
|
||||
case "object":
|
||||
return "object";
|
||||
default:
|
||||
if (resolved.properties) {
|
||||
return "object";
|
||||
}
|
||||
if (resolved.items) {
|
||||
return "array";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function firstArrayItemNode(node: JsonSchemaNode | null): JsonSchemaNode | null {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveUnion(node);
|
||||
if (Array.isArray(resolved.items)) {
|
||||
return asObjectNode(resolved.items[0] ?? null);
|
||||
}
|
||||
return asObjectNode(resolved.items);
|
||||
}
|
||||
|
||||
function recordValueNode(node: JsonSchemaNode | null): JsonSchemaNode | null {
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveUnion(node);
|
||||
const properties = resolved.properties ?? {};
|
||||
if (Object.keys(properties).length > 0) {
|
||||
return null;
|
||||
}
|
||||
return asObjectNode(resolved.additionalProperties);
|
||||
}
|
||||
|
||||
function isEditable(path: string, kind: FieldKind): boolean {
|
||||
if (path.includes("*") || path.includes("[]")) {
|
||||
return false;
|
||||
}
|
||||
return kind !== "unknown";
|
||||
}
|
||||
|
||||
function buildExplorerSchemaNode(node: JsonSchemaNode | null, depth = 0): ExplorerSchemaNode | null {
|
||||
if (!node || depth > 8) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = resolveUnion(node);
|
||||
const kind = resolveType(resolved);
|
||||
|
||||
const properties: Record<string, ExplorerSchemaNode> = {};
|
||||
for (const [key, child] of Object.entries(resolved.properties ?? {})) {
|
||||
const childSchema = buildExplorerSchemaNode(asObjectNode(child), depth + 1);
|
||||
if (childSchema) {
|
||||
properties[key] = childSchema;
|
||||
}
|
||||
}
|
||||
|
||||
const item = buildExplorerSchemaNode(firstArrayItemNode(resolved), depth + 1);
|
||||
|
||||
const additionalRaw = resolved.additionalProperties;
|
||||
const additionalProperties = buildExplorerSchemaNode(asObjectNode(additionalRaw), depth + 1);
|
||||
const allowsUnknownProperties = additionalRaw === true;
|
||||
|
||||
return {
|
||||
kind,
|
||||
enumValues: enumValuesFromNode(node),
|
||||
properties,
|
||||
item,
|
||||
additionalProperties,
|
||||
allowsUnknownProperties,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExplorerField(path: string, hint: ConfigUiHint | undefined, root: JsonSchemaNode): ExplorerField {
|
||||
const schemaNode = resolveSchemaNode(root, path);
|
||||
const kind = resolveType(schemaNode);
|
||||
const arrayItemNode = kind === "array" ? firstArrayItemNode(schemaNode) : null;
|
||||
const itemKind = arrayItemNode ? resolveType(arrayItemNode) : null;
|
||||
const recordNode = kind === "object" ? recordValueNode(schemaNode) : null;
|
||||
const recordValueKind = recordNode ? resolveType(recordNode) : null;
|
||||
|
||||
return {
|
||||
path,
|
||||
label: hint?.label?.trim() || humanizeKey(lastPathSegment(path)),
|
||||
help: hint?.help?.trim() ?? schemaNode?.description?.trim() ?? "",
|
||||
sensitive: Boolean(hint?.sensitive),
|
||||
advanced: Boolean(hint?.advanced),
|
||||
kind,
|
||||
enumValues: enumValuesFromNode(schemaNode),
|
||||
itemKind,
|
||||
itemEnumValues: enumValuesFromNode(arrayItemNode),
|
||||
recordValueKind,
|
||||
recordEnumValues: enumValuesFromNode(recordNode),
|
||||
hasDefault: schemaNode?.default !== undefined,
|
||||
editable: isEditable(path, kind),
|
||||
schemaNode: buildExplorerSchemaNode(schemaNode),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveExplorerField(path: string): ExplorerField | null {
|
||||
const context = getSchemaContext();
|
||||
const hint = context.uiHints[path];
|
||||
const schemaNode = resolveSchemaNode(context.schemaRoot, path);
|
||||
if (!schemaNode && !hint) {
|
||||
return null;
|
||||
}
|
||||
const field = buildExplorerField(path, hint, context.schemaRoot);
|
||||
return field.kind === "unknown" ? null : field;
|
||||
}
|
||||
|
||||
export function buildExplorerSnapshot(): ExplorerSnapshot {
|
||||
const context = getSchemaContext();
|
||||
const uiHints = context.uiHints;
|
||||
const schemaRoot = context.schemaRoot;
|
||||
const schemaProperties = schemaRoot.properties ?? {};
|
||||
|
||||
const sections = new Map<string, ExplorerSection>();
|
||||
|
||||
for (const [path, hint] of Object.entries(uiHints)) {
|
||||
if (!isSectionHint(path, hint)) {
|
||||
continue;
|
||||
}
|
||||
sections.set(path, {
|
||||
id: path,
|
||||
label: hint.label?.trim() || hint.group?.trim() || humanizeKey(path),
|
||||
order: hint.order,
|
||||
description: "",
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [rootKey, node] of Object.entries(schemaProperties)) {
|
||||
if (sections.has(rootKey)) {
|
||||
const existing = sections.get(rootKey);
|
||||
if (existing) {
|
||||
existing.description = node.description?.trim() ?? existing.description;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const rootHint = uiHints[rootKey];
|
||||
sections.set(rootKey, {
|
||||
id: rootKey,
|
||||
label: rootHint?.label?.trim() || humanizeKey(rootKey),
|
||||
order: rootHint?.order ?? SECTION_FALLBACK_ORDER,
|
||||
description: node.description?.trim() ?? rootHint?.help?.trim() ?? "",
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [path, hint] of Object.entries(uiHints)) {
|
||||
const rootKey = firstPathSegment(path);
|
||||
if (!rootKey) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sections.has(rootKey)) {
|
||||
sections.set(rootKey, {
|
||||
id: rootKey,
|
||||
label: humanizeKey(rootKey),
|
||||
order: SECTION_FALLBACK_ORDER,
|
||||
description: "",
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (isSectionHint(path, hint)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = sections.get(rootKey);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = buildExplorerField(path, hint, schemaRoot);
|
||||
if (field.kind === "unknown") {
|
||||
// Ignore hint-only fields that do not resolve against the current schema.
|
||||
continue;
|
||||
}
|
||||
target.fields.push(field);
|
||||
}
|
||||
|
||||
const orderedSections = Array.from(sections.values())
|
||||
.map((section) => {
|
||||
const sorted = section.fields.toSorted(fieldSort);
|
||||
return { ...section, fields: pruneRedundantCompositeFields(sorted) };
|
||||
})
|
||||
.filter((section) => section.fields.length > 0)
|
||||
.toSorted(sectionSort);
|
||||
|
||||
const fieldCount = orderedSections.reduce((sum, section) => sum + section.fields.length, 0);
|
||||
|
||||
return {
|
||||
version: context.version,
|
||||
generatedAt: context.generatedAt,
|
||||
sectionCount: orderedSections.length,
|
||||
fieldCount,
|
||||
sections: orderedSections,
|
||||
};
|
||||
}
|
||||
34
apps/config-builder/src/lib/validation.test.ts
Normal file
34
apps/config-builder/src/lib/validation.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateConfigDraft } from "./validation.ts";
|
||||
|
||||
describe("validateConfigDraft", () => {
|
||||
it("accepts empty drafts", () => {
|
||||
const result = validateConfigDraft({});
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("collects issues by path and section", () => {
|
||||
const result = validateConfigDraft({
|
||||
gateway: {
|
||||
auth: {
|
||||
token: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.length).toBeGreaterThan(0);
|
||||
expect(result.issuesByPath["gateway.auth.token"]?.length).toBeGreaterThan(0);
|
||||
expect(result.sectionErrorCounts.gateway).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("tracks root-level schema issues", () => {
|
||||
const result = validateConfigDraft({
|
||||
__unexpected__: true,
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.sectionErrorCounts.root).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
73
apps/config-builder/src/lib/validation.ts
Normal file
73
apps/config-builder/src/lib/validation.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { OpenClawSchema } from "@openclaw/config/zod-schema.ts";
|
||||
import type { ConfigDraft } from "./config-store.ts";
|
||||
|
||||
export type ValidationIssue = {
|
||||
path: string;
|
||||
section: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ValidationResult = {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
issuesByPath: Record<string, string[]>;
|
||||
sectionErrorCounts: Record<string, number>;
|
||||
};
|
||||
|
||||
function issuePath(path: Array<string | number>): string {
|
||||
if (path.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return path
|
||||
.map((segment) => (typeof segment === "number" ? String(segment) : segment))
|
||||
.join(".");
|
||||
}
|
||||
|
||||
function issueSection(path: string): string {
|
||||
if (!path) {
|
||||
return "root";
|
||||
}
|
||||
const [section] = path.split(".");
|
||||
return section?.trim() || "root";
|
||||
}
|
||||
|
||||
export function validateConfigDraft(config: ConfigDraft): ValidationResult {
|
||||
const parsed = OpenClawSchema.safeParse(config);
|
||||
if (parsed.success) {
|
||||
return {
|
||||
valid: true,
|
||||
issues: [],
|
||||
issuesByPath: {},
|
||||
sectionErrorCounts: {},
|
||||
};
|
||||
}
|
||||
|
||||
const issues: ValidationIssue[] = parsed.error.issues.map((issue) => {
|
||||
const path = issuePath(issue.path);
|
||||
return {
|
||||
path,
|
||||
section: issueSection(path),
|
||||
message: issue.message,
|
||||
};
|
||||
});
|
||||
|
||||
const issuesByPath: Record<string, string[]> = {};
|
||||
const sectionErrorCounts: Record<string, number> = {};
|
||||
|
||||
for (const issue of issues) {
|
||||
const key = issue.path;
|
||||
if (!issuesByPath[key]) {
|
||||
issuesByPath[key] = [];
|
||||
}
|
||||
issuesByPath[key].push(issue.message);
|
||||
|
||||
sectionErrorCounts[issue.section] = (sectionErrorCounts[issue.section] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
issues,
|
||||
issuesByPath,
|
||||
sectionErrorCounts,
|
||||
};
|
||||
}
|
||||
2
apps/config-builder/src/main.ts
Normal file
2
apps/config-builder/src/main.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "./styles.css";
|
||||
import "./ui/app.ts";
|
||||
10
apps/config-builder/src/shims/channel-registry.ts
Normal file
10
apps/config-builder/src/shims/channel-registry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Browser-safe channel ID shim for config-builder schema imports.
|
||||
export const CHANNEL_IDS = [
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"discord",
|
||||
"googlechat",
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
] as const;
|
||||
3
apps/config-builder/src/shims/version.ts
Normal file
3
apps/config-builder/src/shims/version.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Browser-safe version shim for config-builder schema imports.
|
||||
// Real gateway/runtime paths can still use src/version.ts.
|
||||
export const VERSION = "dev";
|
||||
1832
apps/config-builder/src/styles.css
Normal file
1832
apps/config-builder/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1398
apps/config-builder/src/ui/app.ts
Normal file
1398
apps/config-builder/src/ui/app.ts
Normal file
File diff suppressed because it is too large
Load Diff
729
apps/config-builder/src/ui/components/field-renderer.ts
Normal file
729
apps/config-builder/src/ui/components/field-renderer.ts
Normal file
@@ -0,0 +1,729 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import type { ExplorerField, ExplorerSchemaNode, FieldKind } from "../../lib/schema-spike.ts";
|
||||
|
||||
type FieldRendererParams = {
|
||||
field: ExplorerField;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onClear: () => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
suggestions?: string[];
|
||||
};
|
||||
|
||||
type ScalarKind = "string" | "number" | "integer" | "boolean" | "enum";
|
||||
|
||||
type ScalarControlParams = {
|
||||
kind: ScalarKind;
|
||||
enumValues: string[];
|
||||
value: unknown;
|
||||
sensitive?: boolean;
|
||||
compact?: boolean;
|
||||
onSet: (value: unknown) => void;
|
||||
onClear?: () => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
suggestions?: string[];
|
||||
};
|
||||
|
||||
type NodeRendererParams = {
|
||||
node: ExplorerSchemaNode | null;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onClear?: () => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
depth?: number;
|
||||
compact?: boolean;
|
||||
suggestions?: string[];
|
||||
};
|
||||
|
||||
const MAX_EDITOR_DEPTH = 6;
|
||||
|
||||
function humanizeKey(value: string): string {
|
||||
if (!value.trim()) {
|
||||
return value;
|
||||
}
|
||||
return value
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/^./, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function defaultValueForKind(kind: FieldKind, enumValues: string[] = []): unknown {
|
||||
if (kind === "boolean") {
|
||||
return false;
|
||||
}
|
||||
if (kind === "number" || kind === "integer") {
|
||||
return 0;
|
||||
}
|
||||
if (kind === "array") {
|
||||
return [];
|
||||
}
|
||||
if (kind === "object") {
|
||||
return {};
|
||||
}
|
||||
if (kind === "enum") {
|
||||
return enumValues[0] ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function defaultValueForNode(node: ExplorerSchemaNode | null): unknown {
|
||||
if (!node) {
|
||||
return "";
|
||||
}
|
||||
return defaultValueForKind(node.kind, node.enumValues);
|
||||
}
|
||||
|
||||
function parseScalar(kind: FieldKind, raw: string): unknown {
|
||||
if (kind === "number") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error("Enter a valid number.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (kind === "integer") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isNaN(parsed) || !Number.isFinite(parsed)) {
|
||||
throw new Error("Enter a valid integer.");
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
if (kind === "boolean") {
|
||||
if (raw === "true") {
|
||||
return true;
|
||||
}
|
||||
if (raw === "false") {
|
||||
return false;
|
||||
}
|
||||
throw new Error("Use true or false.");
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): string {
|
||||
if (value === undefined) {
|
||||
return "{}";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? "{}";
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function scalarInputValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
||||
return String(value);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeSuggestion(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function dedupeSuggestions(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const entry of values) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeSuggestion(trimmed);
|
||||
if (seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
out.push(trimmed);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function subsequenceScore(query: string, candidate: string): number {
|
||||
let qi = 0;
|
||||
let score = 0;
|
||||
for (let i = 0; i < candidate.length && qi < query.length; i += 1) {
|
||||
if (candidate[i] === query[qi]) {
|
||||
score += i;
|
||||
qi += 1;
|
||||
}
|
||||
}
|
||||
if (qi !== query.length) {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function fuzzyFilterSuggestions(options: string[], query: string, limit = 8): string[] {
|
||||
const unique = dedupeSuggestions(options);
|
||||
const normalizedQuery = normalizeSuggestion(query);
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return unique.slice(0, limit);
|
||||
}
|
||||
|
||||
const ranked = unique
|
||||
.map((entry) => {
|
||||
const normalized = normalizeSuggestion(entry);
|
||||
|
||||
if (normalized === normalizedQuery) {
|
||||
return { entry, score: 0 };
|
||||
}
|
||||
|
||||
if (normalized.startsWith(normalizedQuery)) {
|
||||
return { entry, score: 1 + normalized.length / 1000 };
|
||||
}
|
||||
|
||||
const includesAt = normalized.indexOf(normalizedQuery);
|
||||
if (includesAt >= 0) {
|
||||
return { entry, score: 2 + includesAt / 100 };
|
||||
}
|
||||
|
||||
const subseq = subsequenceScore(normalizedQuery, normalized);
|
||||
if (Number.isFinite(subseq)) {
|
||||
return { entry, score: 3 + subseq / 1000 };
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is { entry: string; score: number } => entry !== null)
|
||||
.toSorted((a, b) => {
|
||||
if (a.score !== b.score) {
|
||||
return a.score - b.score;
|
||||
}
|
||||
if (a.entry.length !== b.entry.length) {
|
||||
return a.entry.length - b.entry.length;
|
||||
}
|
||||
return a.entry.localeCompare(b.entry);
|
||||
});
|
||||
|
||||
return ranked.slice(0, limit).map((entry) => entry.entry);
|
||||
}
|
||||
|
||||
function renderJsonControl(params: {
|
||||
kind: FieldKind;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
}): TemplateResult {
|
||||
const { kind, value, onSet, onValidationError } = params;
|
||||
const fallback = kind === "array" ? [] : {};
|
||||
|
||||
return html`
|
||||
<label class="cfg-field">
|
||||
<span class="cfg-field__help">Edit as JSON (${kind})</span>
|
||||
<textarea
|
||||
class="cfg-textarea"
|
||||
rows="4"
|
||||
.value=${jsonValue(value ?? fallback)}
|
||||
@change=${(event: Event) => {
|
||||
const target = event.target as HTMLTextAreaElement;
|
||||
const raw = target.value.trim();
|
||||
if (!raw) {
|
||||
onSet(fallback);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onSet(JSON.parse(raw));
|
||||
} catch {
|
||||
onValidationError?.("Invalid JSON value.");
|
||||
target.value = jsonValue(value ?? fallback);
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderScalarControl(params: ScalarControlParams): TemplateResult {
|
||||
const { kind, enumValues, value, sensitive, compact, onSet, onClear, onValidationError, suggestions } =
|
||||
params;
|
||||
|
||||
if (kind === "boolean") {
|
||||
return html`
|
||||
<label class="cfg-toggle-row builder-toggle-row">
|
||||
<span class="cfg-field__help">Toggle value</span>
|
||||
<div class="cfg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${value === true}
|
||||
@change=${(event: Event) => onSet((event.target as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span class="cfg-toggle__track"></span>
|
||||
</div>
|
||||
</label>
|
||||
`;
|
||||
}
|
||||
|
||||
if (kind === "enum") {
|
||||
const selected = typeof value === "string" ? value : "";
|
||||
|
||||
if (!compact && enumValues.length > 0 && enumValues.length <= 4) {
|
||||
return html`
|
||||
<div class="cfg-segmented">
|
||||
${enumValues.map(
|
||||
(entry) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-segmented__btn ${entry === selected ? "active" : ""}"
|
||||
@click=${() => onSet(entry)}
|
||||
>
|
||||
${entry}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
${onClear
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-segmented__btn ${selected ? "" : "active"}"
|
||||
@click=${onClear}
|
||||
>
|
||||
unset
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<select
|
||||
class="cfg-select ${compact ? "cfg-select--sm" : ""}"
|
||||
.value=${selected}
|
||||
@change=${(event: Event) => {
|
||||
const next = (event.target as HTMLSelectElement).value;
|
||||
if (!next) {
|
||||
if (onClear) {
|
||||
onClear();
|
||||
} else if (enumValues[0]) {
|
||||
onSet(enumValues[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
${onClear ? html`<option value="">(unset)</option>` : nothing}
|
||||
${enumValues.map((entry) => html`<option value=${entry}>${entry}</option>`) }
|
||||
</select>
|
||||
`;
|
||||
}
|
||||
|
||||
const inputType = kind === "number" || kind === "integer" ? "number" : "text";
|
||||
const inputValue = scalarInputValue(value);
|
||||
|
||||
const applyRawValue = (raw: string) => {
|
||||
if (raw.trim() === "") {
|
||||
if (onClear) {
|
||||
onClear();
|
||||
} else {
|
||||
onSet(defaultValueForKind(kind));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onSet(parseScalar(kind, raw));
|
||||
} catch (error) {
|
||||
onValidationError?.(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const input = html`
|
||||
<input
|
||||
class="cfg-input ${compact ? "cfg-input--sm" : ""}"
|
||||
type=${sensitive ? "password" : inputType}
|
||||
.value=${inputValue}
|
||||
@input=${(event: Event) => {
|
||||
applyRawValue((event.target as HTMLInputElement).value);
|
||||
}}
|
||||
/>
|
||||
`;
|
||||
|
||||
if (kind !== "string") {
|
||||
return input;
|
||||
}
|
||||
|
||||
const filteredSuggestions = fuzzyFilterSuggestions(suggestions ?? [], inputValue);
|
||||
if (filteredSuggestions.length === 0) {
|
||||
return input;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="cb-typeahead ${compact ? "cb-typeahead--compact" : ""}">
|
||||
${input}
|
||||
<div class="cb-typeahead__menu" role="listbox" aria-label="Suggestions">
|
||||
${filteredSuggestions.map((entry) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="cb-typeahead__option"
|
||||
@mousedown=${(event: Event) => event.preventDefault()}
|
||||
@click=${() => onSet(entry)}
|
||||
>
|
||||
${entry}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderArrayNodeControl(params: {
|
||||
node: ExplorerSchemaNode;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
depth: number;
|
||||
suggestions?: string[];
|
||||
}): TemplateResult {
|
||||
const { node, value, onSet, onValidationError, depth, suggestions } = params;
|
||||
const list = asArray(value);
|
||||
const itemNode = node.item;
|
||||
|
||||
if (!itemNode || itemNode.kind === "unknown") {
|
||||
return renderJsonControl({ kind: "array", value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="cfg-array">
|
||||
<div class="cfg-array__header">
|
||||
<span class="cfg-array__label">Items</span>
|
||||
<span class="cfg-array__count">${list.length} item${list.length === 1 ? "" : "s"}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-array__add"
|
||||
@click=${() => onSet([...list, defaultValueForNode(itemNode)])}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${list.length === 0
|
||||
? html`<div class="cfg-array__empty">No items yet.</div>`
|
||||
: html`
|
||||
<div class="cfg-array__items">
|
||||
${list.map((item, index) =>
|
||||
html`
|
||||
<div class="cfg-array__item">
|
||||
<div class="cfg-array__item-header">
|
||||
<span class="cfg-array__item-index">#${index + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-array__item-remove"
|
||||
title="Remove item"
|
||||
@click=${() => {
|
||||
const next = [...list];
|
||||
next.splice(index, 1);
|
||||
onSet(next);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="cfg-array__item-content">
|
||||
${renderNodeEditor({
|
||||
node: itemNode,
|
||||
value: item,
|
||||
onSet: (nextValue) => {
|
||||
const next = [...list];
|
||||
next[index] = nextValue;
|
||||
onSet(next);
|
||||
},
|
||||
onValidationError,
|
||||
depth: depth + 1,
|
||||
compact: true,
|
||||
suggestions,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderObjectNodeControl(params: {
|
||||
node: ExplorerSchemaNode;
|
||||
value: unknown;
|
||||
onSet: (value: unknown) => void;
|
||||
onValidationError?: (message: string) => void;
|
||||
depth: number;
|
||||
suggestions?: string[];
|
||||
}): TemplateResult {
|
||||
const { node, value, onSet, onValidationError, depth, suggestions } = params;
|
||||
const record = asObject(value);
|
||||
|
||||
const fixedEntries = Object.entries(node.properties);
|
||||
const fixedKeys = new Set(fixedEntries.map(([key]) => key));
|
||||
|
||||
const extraSchema = node.additionalProperties;
|
||||
const extraEntries = Object.entries(record).filter(([key]) => !fixedKeys.has(key));
|
||||
|
||||
const hasFixed = fixedEntries.length > 0;
|
||||
const canEditExtras = Boolean(extraSchema && extraSchema.kind !== "unknown");
|
||||
|
||||
if (!hasFixed && !canEditExtras && node.allowsUnknownProperties) {
|
||||
return renderJsonControl({ kind: "object", value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
const setChildValue = (key: string, nextValue: unknown) => {
|
||||
const next = { ...record };
|
||||
next[key] = nextValue;
|
||||
onSet(next);
|
||||
};
|
||||
|
||||
const clearChildValue = (key: string) => {
|
||||
const next = { ...record };
|
||||
delete next[key];
|
||||
onSet(next);
|
||||
};
|
||||
|
||||
const addExtraEntry = () => {
|
||||
if (!extraSchema) {
|
||||
return;
|
||||
}
|
||||
const next = { ...record };
|
||||
let index = 1;
|
||||
let key = `key-${index}`;
|
||||
while (key in next) {
|
||||
index += 1;
|
||||
key = `key-${index}`;
|
||||
}
|
||||
next[key] = defaultValueForNode(extraSchema);
|
||||
onSet(next);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="cfg-object-stack">
|
||||
${hasFixed
|
||||
? html`
|
||||
<div class="cfg-map">
|
||||
<div class="cfg-map__header">
|
||||
<span class="cfg-map__label">Fields</span>
|
||||
</div>
|
||||
<div class="cfg-map__items">
|
||||
${fixedEntries.map(([key, childNode]) => {
|
||||
const childValue = record[key];
|
||||
const hasValue = childValue !== undefined;
|
||||
|
||||
return html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-key">
|
||||
<span class="cfg-field__help">${humanizeKey(key)}</span>
|
||||
</div>
|
||||
<div class="cfg-map__item-value">
|
||||
${renderNodeEditor({
|
||||
node: childNode,
|
||||
value: childValue,
|
||||
onSet: (nextValue) => setChildValue(key, nextValue),
|
||||
onClear: () => clearChildValue(key),
|
||||
onValidationError,
|
||||
depth: depth + 1,
|
||||
compact: true,
|
||||
suggestions,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
title="Clear field"
|
||||
?disabled=${!hasValue}
|
||||
@click=${() => clearChildValue(key)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${canEditExtras && extraSchema
|
||||
? html`
|
||||
<div class="cfg-map">
|
||||
<div class="cfg-map__header">
|
||||
<span class="cfg-map__label">Entries</span>
|
||||
<button type="button" class="cfg-map__add" @click=${addExtraEntry}>Add Entry</button>
|
||||
</div>
|
||||
|
||||
${extraEntries.length === 0
|
||||
? html`<div class="cfg-map__empty">No entries yet.</div>`
|
||||
: html`
|
||||
<div class="cfg-map__items">
|
||||
${extraEntries.map(([key, entryValue]) =>
|
||||
html`
|
||||
<div class="cfg-map__item">
|
||||
<div class="cfg-map__item-key">
|
||||
<input
|
||||
type="text"
|
||||
class="cfg-input cfg-input--sm"
|
||||
.value=${key}
|
||||
@change=${(event: Event) => {
|
||||
const nextKey = (event.target as HTMLInputElement).value.trim();
|
||||
if (!nextKey || nextKey === key || nextKey in record) {
|
||||
return;
|
||||
}
|
||||
const next = { ...record };
|
||||
next[nextKey] = next[key];
|
||||
delete next[key];
|
||||
onSet(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cfg-map__item-value">
|
||||
${renderNodeEditor({
|
||||
node: extraSchema,
|
||||
value: entryValue,
|
||||
onSet: (nextValue) => setChildValue(key, nextValue),
|
||||
onValidationError,
|
||||
depth: depth + 1,
|
||||
compact: true,
|
||||
suggestions,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cfg-map__item-remove"
|
||||
title="Remove entry"
|
||||
@click=${() => clearChildValue(key)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
${!hasFixed && !canEditExtras && !node.allowsUnknownProperties
|
||||
? html`<div class="cfg-field__help">No editable keys in this object schema.</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNodeEditor(params: NodeRendererParams): TemplateResult {
|
||||
const { node, value, onSet, onClear, onValidationError, suggestions } = params;
|
||||
const depth = params.depth ?? 0;
|
||||
const compact = params.compact ?? false;
|
||||
|
||||
if (!node || depth > MAX_EDITOR_DEPTH) {
|
||||
return renderJsonControl({ kind: "object", value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
if (
|
||||
node.kind === "string" ||
|
||||
node.kind === "number" ||
|
||||
node.kind === "integer" ||
|
||||
node.kind === "boolean" ||
|
||||
node.kind === "enum"
|
||||
) {
|
||||
return renderScalarControl({
|
||||
kind: node.kind,
|
||||
enumValues: node.enumValues,
|
||||
value,
|
||||
onSet,
|
||||
onClear,
|
||||
onValidationError,
|
||||
compact,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
if (node.kind === "array") {
|
||||
return renderArrayNodeControl({
|
||||
node,
|
||||
value,
|
||||
onSet,
|
||||
onValidationError,
|
||||
depth,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
if (node.kind === "object") {
|
||||
return renderObjectNodeControl({
|
||||
node,
|
||||
value,
|
||||
onSet,
|
||||
onValidationError,
|
||||
depth,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
return renderJsonControl({ kind: node.kind, value, onSet, onValidationError });
|
||||
}
|
||||
|
||||
export function renderFieldEditor(params: FieldRendererParams): TemplateResult | typeof nothing {
|
||||
const { field, value, onSet, onClear, onValidationError, suggestions } = params;
|
||||
|
||||
if (!field.editable) {
|
||||
return html`<div class="cfg-field__help">Read-only in this phase.</div>`;
|
||||
}
|
||||
|
||||
if (
|
||||
field.kind === "string" ||
|
||||
field.kind === "number" ||
|
||||
field.kind === "integer" ||
|
||||
field.kind === "boolean" ||
|
||||
field.kind === "enum"
|
||||
) {
|
||||
return renderScalarControl({
|
||||
kind: field.kind,
|
||||
enumValues: field.enumValues,
|
||||
value,
|
||||
sensitive: field.sensitive,
|
||||
onSet,
|
||||
onClear,
|
||||
onValidationError,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
if (field.kind === "array" || field.kind === "object") {
|
||||
return renderNodeEditor({
|
||||
node: field.schemaNode,
|
||||
value,
|
||||
onSet,
|
||||
onValidationError,
|
||||
depth: 0,
|
||||
suggestions,
|
||||
});
|
||||
}
|
||||
|
||||
return html`<div class="cfg-field__help">Unsupported schema node.</div>`;
|
||||
}
|
||||
329
apps/config-builder/src/ui/components/icons.ts
Normal file
329
apps/config-builder/src/ui/components/icons.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { svg, type TemplateResult } from "lit";
|
||||
|
||||
// Lucide-style SVG icons used throughout the config builder.
|
||||
// Ported from the OpenClaw web UI icon set + additions.
|
||||
|
||||
function icon(content: TemplateResult): TemplateResult {
|
||||
return svg`<svg
|
||||
class="cb-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
${content}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// --- Section icons (match web UI sidebar) ---
|
||||
|
||||
export const iconGateway = icon(svg`
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
`);
|
||||
|
||||
export const iconChannels = icon(svg`
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
`);
|
||||
|
||||
export const iconAgents = icon(svg`
|
||||
<path d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2z"></path>
|
||||
<circle cx="8" cy="14" r="1"></circle>
|
||||
<circle cx="16" cy="14" r="1"></circle>
|
||||
`);
|
||||
|
||||
export const iconAuth = icon(svg`
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
`);
|
||||
|
||||
export const iconMessages = icon(svg`
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
||||
<polyline points="22,6 12,13 2,6"></polyline>
|
||||
`);
|
||||
|
||||
export const iconTools = icon(svg`
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
|
||||
`);
|
||||
|
||||
export const iconSession = icon(svg`
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||
`);
|
||||
|
||||
export const iconHooks = icon(svg`
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
`);
|
||||
|
||||
export const iconSkills = icon(svg`
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
`);
|
||||
|
||||
export const iconCommands = icon(svg`
|
||||
<polyline points="4 17 10 11 4 5"></polyline>
|
||||
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||
`);
|
||||
|
||||
export const iconModels = icon(svg`
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
`);
|
||||
|
||||
export const iconEnv = icon(svg`
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
`);
|
||||
|
||||
export const iconUpdate = icon(svg`
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
`);
|
||||
|
||||
export const iconLogging = icon(svg`
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||
`);
|
||||
|
||||
export const iconBroadcast = icon(svg`
|
||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
||||
<circle cx="12" cy="12" r="2"></circle>
|
||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
||||
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"></path>
|
||||
`);
|
||||
|
||||
export const iconPlugins = icon(svg`
|
||||
<path d="M12 2v6"></path>
|
||||
<path d="m4.93 10.93 4.24 4.24"></path>
|
||||
<path d="M2 12h6"></path>
|
||||
<path d="m4.93 13.07 4.24-4.24"></path>
|
||||
<path d="M12 22v-6"></path>
|
||||
<path d="m19.07 13.07-4.24-4.24"></path>
|
||||
<path d="M22 12h-6"></path>
|
||||
<path d="m19.07 10.93-4.24 4.24"></path>
|
||||
`);
|
||||
|
||||
export const iconWeb = icon(svg`
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
`);
|
||||
|
||||
export const iconCron = icon(svg`
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<polyline points="12 6 12 12 16 14"></polyline>
|
||||
`);
|
||||
|
||||
export const iconAudio = icon(svg`
|
||||
<path d="M9 18V5l12-2v13"></path>
|
||||
<circle cx="6" cy="18" r="3"></circle>
|
||||
<circle cx="18" cy="16" r="3"></circle>
|
||||
`);
|
||||
|
||||
export const iconUI = icon(svg`
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="9" y1="21" x2="9" y2="9"></line>
|
||||
`);
|
||||
|
||||
export const iconWizard = icon(svg`
|
||||
<path d="M15 4V2"></path>
|
||||
<path d="M15 16v-2"></path>
|
||||
<path d="M8 9h2"></path>
|
||||
<path d="M20 9h2"></path>
|
||||
<path d="M17.8 11.8 19 13"></path>
|
||||
<path d="M15 9h0"></path>
|
||||
<path d="M17.8 6.2 19 5"></path>
|
||||
<path d="m3 21 9-9"></path>
|
||||
<path d="M12.2 6.2 11 5"></path>
|
||||
`);
|
||||
|
||||
export const iconDefault = icon(svg`
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
`);
|
||||
|
||||
// --- UI action icons ---
|
||||
|
||||
export const iconSearch = icon(svg`
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="M21 21l-4.35-4.35"></path>
|
||||
`);
|
||||
|
||||
export const iconChevronDown = icon(svg`
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
`);
|
||||
|
||||
export const iconChevronRight = icon(svg`
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
`);
|
||||
|
||||
export const iconChevronLeft = icon(svg`
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
`);
|
||||
|
||||
export const iconCopy = icon(svg`
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
`);
|
||||
|
||||
export const iconDownload = icon(svg`
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
`);
|
||||
|
||||
export const iconTrash = icon(svg`
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
`);
|
||||
|
||||
export const iconCheck = icon(svg`
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
`);
|
||||
|
||||
export const iconX = icon(svg`
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
`);
|
||||
|
||||
export const iconSparkles = icon(svg`
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"></path>
|
||||
<path d="M5 3v4"></path>
|
||||
<path d="M19 17v4"></path>
|
||||
<path d="M3 5h4"></path>
|
||||
<path d="M17 19h4"></path>
|
||||
`);
|
||||
|
||||
export const iconImport = icon(svg`
|
||||
<path d="M12 3v12"></path>
|
||||
<path d="m8 11 4 4 4-4"></path>
|
||||
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4"></path>
|
||||
`);
|
||||
|
||||
export const iconExternalLink = icon(svg`
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
`);
|
||||
|
||||
export const iconEye = icon(svg`
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
`);
|
||||
|
||||
export const iconEyeOff = icon(svg`
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"></path>
|
||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"></path>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"></path>
|
||||
`);
|
||||
|
||||
export const iconGrid = icon(svg`
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
`);
|
||||
|
||||
export const iconLock = icon(svg`
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
`);
|
||||
|
||||
export const iconShield = icon(svg`
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
`);
|
||||
|
||||
export const iconCode = icon(svg`
|
||||
<polyline points="16 18 22 12 16 6"></polyline>
|
||||
<polyline points="8 6 2 12 8 18"></polyline>
|
||||
`);
|
||||
|
||||
export const iconSun = icon(svg`
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
`);
|
||||
|
||||
export const iconMoon = icon(svg`
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
`);
|
||||
|
||||
export const iconArrowRight = icon(svg`
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
`);
|
||||
|
||||
export const iconArrowLeft = icon(svg`
|
||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
||||
<polyline points="12 19 5 12 12 5"></polyline>
|
||||
`);
|
||||
|
||||
export const iconMoreVertical = icon(svg`
|
||||
<circle cx="12" cy="12" r="1"></circle>
|
||||
<circle cx="12" cy="5" r="1"></circle>
|
||||
<circle cx="12" cy="19" r="1"></circle>
|
||||
`);
|
||||
|
||||
export const iconFile = icon(svg`
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
`);
|
||||
|
||||
export const iconPanelRight = icon(svg`
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="15" y1="3" x2="15" y2="21"></line>
|
||||
`);
|
||||
|
||||
export const iconSidebar = icon(svg`
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<line x1="9" y1="3" x2="9" y2="21"></line>
|
||||
`);
|
||||
|
||||
// --- Section icon lookup ---
|
||||
|
||||
const SECTION_ICON_MAP: Record<string, TemplateResult> = {
|
||||
gateway: iconGateway,
|
||||
channels: iconChannels,
|
||||
agents: iconAgents,
|
||||
auth: iconAuth,
|
||||
messages: iconMessages,
|
||||
tools: iconTools,
|
||||
session: iconSession,
|
||||
hooks: iconHooks,
|
||||
skills: iconSkills,
|
||||
commands: iconCommands,
|
||||
models: iconModels,
|
||||
env: iconEnv,
|
||||
update: iconUpdate,
|
||||
logging: iconLogging,
|
||||
broadcast: iconBroadcast,
|
||||
plugins: iconPlugins,
|
||||
web: iconWeb,
|
||||
cron: iconCron,
|
||||
audio: iconAudio,
|
||||
ui: iconUI,
|
||||
wizard: iconWizard,
|
||||
};
|
||||
|
||||
export function sectionIcon(sectionId: string): TemplateResult {
|
||||
return SECTION_ICON_MAP[sectionId] ?? iconDefault;
|
||||
}
|
||||
247
apps/config-builder/src/ui/components/import-dialog.ts
Normal file
247
apps/config-builder/src/ui/components/import-dialog.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import JSON5 from "json5";
|
||||
import type { ConfigDraft } from "../../lib/config-store.ts";
|
||||
import { iconImport, iconFile, iconX, iconCheck } from "./icons.ts";
|
||||
|
||||
export type ImportDialogState = {
|
||||
open: boolean;
|
||||
tab: "paste" | "upload";
|
||||
pasteValue: string;
|
||||
error: string | null;
|
||||
dragOver: boolean;
|
||||
};
|
||||
|
||||
export function createImportDialogState(): ImportDialogState {
|
||||
return {
|
||||
open: false,
|
||||
tab: "paste",
|
||||
pasteValue: "",
|
||||
error: null,
|
||||
dragOver: false,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function deepMerge(
|
||||
base: Record<string, unknown>,
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (isRecord(value) && isRecord(result[key])) {
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseInput(raw: string): { config: ConfigDraft; error: string | null } {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return { config: {}, error: "Input is empty." };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON5.parse(trimmed) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
return { config: {}, error: "Parsed value is not an object." };
|
||||
}
|
||||
return { config: parsed, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { config: {}, error: `Parse error: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export type ImportCallbacks = {
|
||||
onReplace: (config: ConfigDraft) => void;
|
||||
onMerge: (config: ConfigDraft) => void;
|
||||
onClose: () => void;
|
||||
onStateChange: (state: ImportDialogState) => void;
|
||||
};
|
||||
|
||||
export function renderImportDialog(
|
||||
state: ImportDialogState,
|
||||
hasExistingDraft: boolean,
|
||||
callbacks: ImportCallbacks,
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!state.open) {return nothing;}
|
||||
|
||||
const handlePasteImport = (mode: "replace" | "merge") => {
|
||||
const { config, error } = parseInput(state.pasteValue);
|
||||
if (error) {
|
||||
callbacks.onStateChange({ ...state, error });
|
||||
return;
|
||||
}
|
||||
if (mode === "replace") {
|
||||
callbacks.onReplace(config);
|
||||
} else {
|
||||
callbacks.onMerge(config);
|
||||
}
|
||||
callbacks.onClose();
|
||||
};
|
||||
|
||||
const handleFileContent = (content: string, mode: "replace" | "merge") => {
|
||||
const { config, error } = parseInput(content);
|
||||
if (error) {
|
||||
callbacks.onStateChange({ ...state, error });
|
||||
return;
|
||||
}
|
||||
if (mode === "replace") {
|
||||
callbacks.onReplace(config);
|
||||
} else {
|
||||
callbacks.onMerge(config);
|
||||
}
|
||||
callbacks.onClose();
|
||||
};
|
||||
|
||||
const handleFileDrop = (e: DragEvent, mode: "replace" | "merge") => {
|
||||
e.preventDefault();
|
||||
callbacks.onStateChange({ ...state, dragOver: false });
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) {return;}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
handleFileContent(reader.result, mode);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleFilePick = (e: Event, mode: "replace" | "merge") => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) {return;}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
handleFileContent(reader.result, mode);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="cb-palette-overlay" @click=${(e: Event) => {
|
||||
if (e.target === e.currentTarget) {callbacks.onClose();}
|
||||
}}>
|
||||
<div class="cb-import-dialog">
|
||||
<div class="cb-import-dialog__header">
|
||||
<div class="cb-import-dialog__title">
|
||||
${iconImport} Import Config
|
||||
</div>
|
||||
<button class="cb-import-dialog__close" @click=${callbacks.onClose}>
|
||||
${iconX}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cb-import-dialog__tabs">
|
||||
<button
|
||||
class="cb-import-dialog__tab ${state.tab === "paste" ? "active" : ""}"
|
||||
@click=${() => callbacks.onStateChange({ ...state, tab: "paste", error: null })}
|
||||
>
|
||||
Paste JSON5
|
||||
</button>
|
||||
<button
|
||||
class="cb-import-dialog__tab ${state.tab === "upload" ? "active" : ""}"
|
||||
@click=${() => callbacks.onStateChange({ ...state, tab: "upload", error: null })}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cb-import-dialog__body">
|
||||
${state.tab === "paste"
|
||||
? html`
|
||||
<textarea
|
||||
class="cb-import-dialog__textarea"
|
||||
rows="10"
|
||||
placeholder='Paste your openclaw.json or JSON5 content here…\n\n{\n gateway: { port: 18789 },\n agents: { ... }\n}'
|
||||
.value=${state.pasteValue}
|
||||
@input=${(e: Event) => {
|
||||
callbacks.onStateChange({
|
||||
...state,
|
||||
pasteValue: (e.target as HTMLTextAreaElement).value,
|
||||
error: null,
|
||||
});
|
||||
}}
|
||||
></textarea>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="cb-import-dialog__drop-zone ${state.dragOver ? "cb-import-dialog__drop-zone--active" : ""}"
|
||||
@dragover=${(e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!state.dragOver) {
|
||||
callbacks.onStateChange({ ...state, dragOver: true });
|
||||
}
|
||||
}}
|
||||
@dragleave=${() => {
|
||||
callbacks.onStateChange({ ...state, dragOver: false });
|
||||
}}
|
||||
@drop=${(e: DragEvent) => handleFileDrop(e, hasExistingDraft ? "merge" : "replace")}
|
||||
>
|
||||
<div class="cb-import-dialog__drop-icon">${iconFile}</div>
|
||||
<div class="cb-import-dialog__drop-text">
|
||||
Drop your config file here
|
||||
</div>
|
||||
<div class="cb-import-dialog__drop-sub">
|
||||
or
|
||||
<label class="cb-import-dialog__file-label">
|
||||
browse files
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.json5,.jsonc"
|
||||
style="display:none"
|
||||
@change=${(e: Event) => handleFilePick(e, hasExistingDraft ? "merge" : "replace")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${state.error
|
||||
? html`<div class="cb-import-dialog__error">${state.error}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${state.tab === "paste"
|
||||
? html`
|
||||
<div class="cb-import-dialog__footer">
|
||||
${hasExistingDraft
|
||||
? html`
|
||||
<button
|
||||
class="btn btn--sm"
|
||||
?disabled=${!state.pasteValue.trim()}
|
||||
@click=${() => handlePasteImport("merge")}
|
||||
>
|
||||
Merge with draft
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--sm danger"
|
||||
?disabled=${!state.pasteValue.trim()}
|
||||
@click=${() => handlePasteImport("replace")}
|
||||
>
|
||||
Replace draft
|
||||
</button>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
class="btn btn--sm primary"
|
||||
?disabled=${!state.pasteValue.trim()}
|
||||
@click=${() => handlePasteImport("replace")}
|
||||
>
|
||||
${iconCheck} Import
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
20
apps/config-builder/src/ui/navigation.test.ts
Normal file
20
apps/config-builder/src/ui/navigation.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { modeToHash, parseModeFromHash } from "./navigation.ts";
|
||||
|
||||
describe("navigation mode hash", () => {
|
||||
it("parses known hashes", () => {
|
||||
expect(parseModeFromHash("#/wizard")).toBe("wizard");
|
||||
expect(parseModeFromHash("#/explorer")).toBe("explorer");
|
||||
expect(parseModeFromHash("#/")).toBe("landing");
|
||||
});
|
||||
|
||||
it("falls back to landing for unknown hash", () => {
|
||||
expect(parseModeFromHash("#/unknown")).toBe("landing");
|
||||
});
|
||||
|
||||
it("builds hashes for modes", () => {
|
||||
expect(modeToHash("landing")).toBe("#/");
|
||||
expect(modeToHash("explorer")).toBe("#/explorer");
|
||||
expect(modeToHash("wizard")).toBe("#/wizard");
|
||||
});
|
||||
});
|
||||
29
apps/config-builder/src/ui/navigation.ts
Normal file
29
apps/config-builder/src/ui/navigation.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export type ConfigBuilderMode = "landing" | "explorer" | "wizard";
|
||||
|
||||
export function parseModeFromHash(hash: string): ConfigBuilderMode {
|
||||
const normalized = hash.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "landing";
|
||||
}
|
||||
|
||||
if (normalized === "#/wizard" || normalized === "#wizard") {
|
||||
return "wizard";
|
||||
}
|
||||
if (normalized === "#/explorer" || normalized === "#explorer") {
|
||||
return "explorer";
|
||||
}
|
||||
if (normalized === "#/" || normalized === "#") {
|
||||
return "landing";
|
||||
}
|
||||
return "landing";
|
||||
}
|
||||
|
||||
export function modeToHash(mode: ConfigBuilderMode): string {
|
||||
if (mode === "wizard") {
|
||||
return "#/wizard";
|
||||
}
|
||||
if (mode === "explorer") {
|
||||
return "#/explorer";
|
||||
}
|
||||
return "#/";
|
||||
}
|
||||
15
apps/config-builder/src/ui/wizard.test.ts
Normal file
15
apps/config-builder/src/ui/wizard.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WIZARD_STEPS, wizardStepFields } from "./wizard.ts";
|
||||
|
||||
describe("wizard step definitions", () => {
|
||||
it("defines the expected number of curated steps", () => {
|
||||
expect(WIZARD_STEPS).toHaveLength(7);
|
||||
});
|
||||
|
||||
it("resolves all configured fields to schema metadata", () => {
|
||||
for (const step of WIZARD_STEPS) {
|
||||
const fields = wizardStepFields(step);
|
||||
expect(fields).toHaveLength(step.fields.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
105
apps/config-builder/src/ui/wizard.ts
Normal file
105
apps/config-builder/src/ui/wizard.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { ExplorerField } from "../lib/schema-spike.ts";
|
||||
import { resolveExplorerField } from "../lib/schema-spike.ts";
|
||||
|
||||
export type WizardStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
fields: string[];
|
||||
};
|
||||
|
||||
export const WIZARD_STEPS: WizardStep[] = [
|
||||
{
|
||||
id: "gateway",
|
||||
label: "Gateway",
|
||||
description: "Core gateway networking and auth settings.",
|
||||
fields: [
|
||||
"gateway.port",
|
||||
"gateway.mode",
|
||||
"gateway.bind",
|
||||
"gateway.auth.mode",
|
||||
"gateway.auth.token",
|
||||
"gateway.auth.password",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
description: "Common channel credentials and DM policies.",
|
||||
fields: [
|
||||
"channels.whatsapp.dmPolicy",
|
||||
"channels.telegram.botToken",
|
||||
"channels.telegram.dmPolicy",
|
||||
"channels.discord.token",
|
||||
"channels.discord.dm.policy",
|
||||
"channels.slack.botToken",
|
||||
"channels.slack.dm.policy",
|
||||
"channels.signal.account",
|
||||
"channels.signal.dmPolicy",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
label: "Agents",
|
||||
description: "Default model + workspace behavior.",
|
||||
fields: [
|
||||
"agents.defaults.model.primary",
|
||||
"agents.defaults.model.fallbacks",
|
||||
"agents.defaults.workspace",
|
||||
"agents.defaults.repoRoot",
|
||||
"agents.defaults.humanDelay.mode",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
label: "Models",
|
||||
description: "Auth and model catalog data.",
|
||||
fields: ["agents.defaults.models", "auth.profiles", "auth.order"],
|
||||
},
|
||||
{
|
||||
id: "messages",
|
||||
label: "Messages",
|
||||
description: "Reply behavior and acknowledgment defaults.",
|
||||
fields: [
|
||||
"messages.ackReaction",
|
||||
"messages.ackReactionScope",
|
||||
"messages.inbound.debounceMs",
|
||||
"channels.telegram.streamMode",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "session",
|
||||
label: "Session",
|
||||
description: "DM scoping and agent-to-agent behavior.",
|
||||
fields: ["session.dmScope", "session.identityLinks", "session.agentToAgent.maxPingPongTurns"],
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
label: "Tools",
|
||||
description: "Web and execution tool defaults.",
|
||||
fields: [
|
||||
"tools.profile",
|
||||
"tools.web.search.enabled",
|
||||
"tools.web.search.provider",
|
||||
"tools.web.search.apiKey",
|
||||
"tools.web.fetch.enabled",
|
||||
"tools.exec.applyPatch.enabled",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function wizardStepFields(step: WizardStep): ExplorerField[] {
|
||||
return step.fields
|
||||
.map((path) => resolveExplorerField(path))
|
||||
.filter((field): field is ExplorerField => field !== null);
|
||||
}
|
||||
|
||||
export function wizardStepByIndex(index: number): WizardStep {
|
||||
const clamped = Math.max(0, Math.min(index, WIZARD_STEPS.length - 1));
|
||||
return WIZARD_STEPS[clamped] ?? WIZARD_STEPS[0] ?? {
|
||||
id: "empty",
|
||||
label: "Empty",
|
||||
description: "No wizard steps configured.",
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
11
apps/config-builder/tsconfig.json
Normal file
11
apps/config-builder/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"@openclaw/config/*": ["../../src/config/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "vite.config.ts"]
|
||||
}
|
||||
5
apps/config-builder/vercel.json
Normal file
5
apps/config-builder/vercel.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"installCommand": "pnpm install",
|
||||
"buildCommand": "vite build --outDir dist",
|
||||
"outputDirectory": "dist"
|
||||
}
|
||||
60
apps/config-builder/vite.config.ts
Normal file
60
apps/config-builder/vite.config.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../..");
|
||||
|
||||
function normalizeBase(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return "/";
|
||||
}
|
||||
if (trimmed === "./") {
|
||||
return "./";
|
||||
}
|
||||
if (trimmed.endsWith("/")) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed}/`;
|
||||
}
|
||||
|
||||
export default defineConfig(() => {
|
||||
const envBase = process.env.OPENCLAW_CONFIG_BUILDER_BASE_PATH?.trim();
|
||||
const base = envBase ? normalizeBase(envBase) : "./";
|
||||
return {
|
||||
base,
|
||||
publicDir: path.resolve(here, "public"),
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "@openclaw/config",
|
||||
replacement: path.resolve(repoRoot, "src/config"),
|
||||
},
|
||||
{
|
||||
// src/config/schema.ts imports ../version.js; redirect to a browser-safe shim.
|
||||
find: "../version.js",
|
||||
replacement: path.resolve(here, "src/shims/version.ts"),
|
||||
},
|
||||
{
|
||||
// src/config/schema.ts imports ../channels/registry.js; redirect to a light shim.
|
||||
find: "../channels/registry.js",
|
||||
replacement: path.resolve(here, "src/shims/channel-registry.ts"),
|
||||
},
|
||||
],
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ["lit"],
|
||||
},
|
||||
build: {
|
||||
outDir: path.resolve(here, "../../dist/config-builder"),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
strictPort: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
18
apps/config-builder/vitest.config.ts
Normal file
18
apps/config-builder/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(here, "../..");
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@openclaw/config": path.resolve(repoRoot, "src/config"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -1,28 +1,66 @@
|
||||
# OpenClaw (iOS)
|
||||
|
||||
Internal-only SwiftUI app scaffold.
|
||||
This is an **alpha** iOS app that connects to an OpenClaw Gateway as a `role: node`.
|
||||
|
||||
Expect rough edges:
|
||||
|
||||
- UI and onboarding are changing quickly.
|
||||
- Background behavior is not stable yet (foreground app is the supported mode right now).
|
||||
- Permissions are opt-in and the app should be treated as sensitive while we harden it.
|
||||
|
||||
## What It Does
|
||||
|
||||
- Connects to a Gateway over `ws://` / `wss://`
|
||||
- Pairs a new device (approved from your bot)
|
||||
- Exposes phone services as node commands (camera, location, photos, calendar, reminders, etc; gated by iOS permissions)
|
||||
- Provides Talk + Chat surfaces (alpha)
|
||||
|
||||
## Pairing (Recommended Flow)
|
||||
|
||||
If your Gateway has the `device-pair` plugin installed:
|
||||
|
||||
1. In Telegram, message your bot: `/pair`
|
||||
2. Copy the **setup code** message
|
||||
3. On iOS: OpenClaw → Settings → Gateway → paste setup code → Connect
|
||||
4. Back in Telegram: `/pair approve`
|
||||
|
||||
## Build And Run
|
||||
|
||||
Prereqs:
|
||||
|
||||
- Xcode (current stable)
|
||||
- `pnpm`
|
||||
- `xcodegen`
|
||||
|
||||
From the repo root:
|
||||
|
||||
## Lint/format (required)
|
||||
```bash
|
||||
brew install swiftformat swiftlint
|
||||
pnpm install
|
||||
pnpm ios:open
|
||||
```
|
||||
|
||||
## Generate the Xcode project
|
||||
Then in Xcode:
|
||||
|
||||
1. Select the `OpenClaw` scheme
|
||||
2. Select a simulator or a connected device
|
||||
3. Run
|
||||
|
||||
If you're using a personal Apple Development team, you may need to change the bundle identifier in Xcode to a unique value so signing succeeds.
|
||||
|
||||
## Build From CLI
|
||||
|
||||
```bash
|
||||
pnpm ios:build
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
xcodegen generate
|
||||
open OpenClaw.xcodeproj
|
||||
xcodebuild test -project OpenClaw.xcodeproj -scheme OpenClaw -destination "platform=iOS Simulator,name=iPhone 17"
|
||||
```
|
||||
|
||||
## Shared packages
|
||||
- `../shared/OpenClawKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).
|
||||
## Shared Code
|
||||
|
||||
## fastlane
|
||||
```bash
|
||||
brew install fastlane
|
||||
|
||||
cd apps/ios
|
||||
fastlane lanes
|
||||
```
|
||||
|
||||
See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.
|
||||
- `apps/shared/OpenClawKit` contains the shared transport/types used by the iOS app.
|
||||
|
||||
167
apps/ios/Sources/Calendar/CalendarService.swift
Normal file
167
apps/ios/Sources/Calendar/CalendarService.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let (start, end) = Self.resolveRange(
|
||||
startISO: params.startISO,
|
||||
endISO: params.endISO)
|
||||
let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
|
||||
let events = store.events(matching: predicate)
|
||||
let limit = max(1, min(params.limit ?? 50, 500))
|
||||
let selected = Array(events.prefix(limit))
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let payload = selected.map { event in
|
||||
OpenClawCalendarEventPayload(
|
||||
identifier: event.eventIdentifier ?? UUID().uuidString,
|
||||
title: event.title ?? "(untitled)",
|
||||
startISO: formatter.string(from: event.startDate),
|
||||
endISO: formatter.string(from: event.endDate),
|
||||
isAllDay: event.isAllDay,
|
||||
location: event.location,
|
||||
calendarTitle: event.calendar.title)
|
||||
}
|
||||
|
||||
return OpenClawCalendarEventsPayload(events: payload)
|
||||
}
|
||||
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Calendar", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_INVALID: title required",
|
||||
])
|
||||
}
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let start = formatter.date(from: params.startISO) else {
|
||||
throw NSError(domain: "Calendar", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_INVALID: startISO required",
|
||||
])
|
||||
}
|
||||
guard let end = formatter.date(from: params.endISO) else {
|
||||
throw NSError(domain: "Calendar", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_INVALID: endISO required",
|
||||
])
|
||||
}
|
||||
|
||||
let event = EKEvent(eventStore: store)
|
||||
event.title = title
|
||||
event.startDate = start
|
||||
event.endDate = end
|
||||
event.isAllDay = params.isAllDay ?? false
|
||||
if let location = params.location?.trimmingCharacters(in: .whitespacesAndNewlines), !location.isEmpty {
|
||||
event.location = location
|
||||
}
|
||||
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
|
||||
event.notes = notes
|
||||
}
|
||||
event.calendar = try Self.resolveCalendar(
|
||||
store: store,
|
||||
calendarId: params.calendarId,
|
||||
calendarTitle: params.calendarTitle)
|
||||
|
||||
try store.save(event, span: .thisEvent)
|
||||
|
||||
let payload = OpenClawCalendarEventPayload(
|
||||
identifier: event.eventIdentifier ?? UUID().uuidString,
|
||||
title: event.title ?? title,
|
||||
startISO: formatter.string(from: event.startDate),
|
||||
endISO: formatter.string(from: event.endDate),
|
||||
isAllDay: event.isAllDay,
|
||||
location: event.location,
|
||||
calendarTitle: event.calendar.title)
|
||||
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
calendarTitle: String?) throws -> EKCalendar
|
||||
{
|
||||
if let id = calendarId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
|
||||
let calendar = store.calendar(withIdentifier: id)
|
||||
{
|
||||
return calendar
|
||||
}
|
||||
|
||||
if let title = calendarTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
if let calendar = store.calendars(for: .event).first(where: {
|
||||
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||
}) {
|
||||
return calendar
|
||||
}
|
||||
throw NSError(domain: "Calendar", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no calendar named \(title)",
|
||||
])
|
||||
}
|
||||
|
||||
if let fallback = store.defaultCalendarForNewEvents {
|
||||
return fallback
|
||||
}
|
||||
|
||||
throw NSError(domain: "Calendar", code: 7, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_NOT_FOUND: no default calendar",
|
||||
])
|
||||
}
|
||||
|
||||
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let start = startISO.flatMap { formatter.date(from: $0) } ?? Date()
|
||||
let end = endISO.flatMap { formatter.date(from: $0) } ?? start.addingTimeInterval(7 * 24 * 3600)
|
||||
return (start, end)
|
||||
}
|
||||
}
|
||||
25
apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift
Normal file
25
apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class NodeCapabilityRouter {
|
||||
enum RouterError: Error {
|
||||
case unknownCommand
|
||||
case handlerUnavailable
|
||||
}
|
||||
|
||||
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
|
||||
|
||||
private let handlers: [String: Handler]
|
||||
|
||||
init(handlers: [String: Handler]) {
|
||||
self.handlers = handlers
|
||||
}
|
||||
|
||||
func handle(_ request: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
guard let handler = handlers[request.command] else {
|
||||
throw RouterError.unknownCommand
|
||||
}
|
||||
return try await handler(request)
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,16 @@ struct ChatSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: OpenClawChatViewModel
|
||||
private let userAccent: Color?
|
||||
private let agentName: String?
|
||||
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, userAccent: Color? = nil) {
|
||||
init(gateway: GatewayNodeSession, sessionKey: String, agentName: String? = nil, userAccent: Color? = nil) {
|
||||
let transport = IOSGatewayChatTransport(gateway: gateway)
|
||||
self._viewModel = State(
|
||||
initialValue: OpenClawChatViewModel(
|
||||
sessionKey: sessionKey,
|
||||
transport: transport))
|
||||
self.userAccent = userAccent
|
||||
self.agentName = agentName
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -22,7 +24,7 @@ struct ChatSheet: View {
|
||||
viewModel: self.viewModel,
|
||||
showsSessionSwitcher: true,
|
||||
userAccent: self.userAccent)
|
||||
.navigationTitle("Chat")
|
||||
.navigationTitle(self.chatTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -36,4 +38,10 @@ struct ChatSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chatTitle: String {
|
||||
let trimmed = (self.agentName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return "Chat" }
|
||||
return "Chat (\(trimmed))"
|
||||
}
|
||||
}
|
||||
|
||||
212
apps/ios/Sources/Contacts/ContactsService.swift
Normal file
212
apps/ios/Sources/Contacts/ContactsService.swift
Normal file
@@ -0,0 +1,212 @@
|
||||
import Contacts
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class ContactsService: ContactsServicing {
|
||||
private static var payloadKeys: [CNKeyDescriptor] {
|
||||
[
|
||||
CNContactIdentifierKey as CNKeyDescriptor,
|
||||
CNContactGivenNameKey as CNKeyDescriptor,
|
||||
CNContactFamilyNameKey as CNKeyDescriptor,
|
||||
CNContactOrganizationNameKey as CNKeyDescriptor,
|
||||
CNContactPhoneNumbersKey as CNKeyDescriptor,
|
||||
CNContactEmailAddressesKey as CNKeyDescriptor,
|
||||
]
|
||||
}
|
||||
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 25, 200))
|
||||
|
||||
var contacts: [CNContact] = []
|
||||
if let query = params.query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty {
|
||||
let predicate = CNContact.predicateForContacts(matchingName: query)
|
||||
contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||
} else {
|
||||
let request = CNContactFetchRequest(keysToFetch: Self.payloadKeys)
|
||||
try store.enumerateContacts(with: request) { contact, stop in
|
||||
contacts.append(contact)
|
||||
if contacts.count >= limit {
|
||||
stop.pointee = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sliced = Array(contacts.prefix(limit))
|
||||
let payload = sliced.map { Self.payload(from: $0) }
|
||||
|
||||
return OpenClawContactsSearchPayload(contacts: payload)
|
||||
}
|
||||
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
|
||||
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let organizationName = params.organizationName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let displayName = params.displayName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let phoneNumbers = Self.normalizeStrings(params.phoneNumbers)
|
||||
let emails = Self.normalizeStrings(params.emails, lowercased: true)
|
||||
|
||||
let hasName = !(givenName ?? "").isEmpty || !(familyName ?? "").isEmpty || !(displayName ?? "").isEmpty
|
||||
let hasOrg = !(organizationName ?? "").isEmpty
|
||||
let hasDetails = !phoneNumbers.isEmpty || !emails.isEmpty
|
||||
guard hasName || hasOrg || hasDetails else {
|
||||
throw NSError(domain: "Contacts", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_INVALID: include a name, organization, phone, or email",
|
||||
])
|
||||
}
|
||||
|
||||
if !phoneNumbers.isEmpty || !emails.isEmpty {
|
||||
if let existing = try Self.findExistingContact(
|
||||
store: store,
|
||||
phoneNumbers: phoneNumbers,
|
||||
emails: emails)
|
||||
{
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: existing))
|
||||
}
|
||||
}
|
||||
|
||||
let contact = CNMutableContact()
|
||||
contact.givenName = givenName ?? ""
|
||||
contact.familyName = familyName ?? ""
|
||||
contact.organizationName = organizationName ?? ""
|
||||
if contact.givenName.isEmpty && contact.familyName.isEmpty, let displayName {
|
||||
contact.givenName = displayName
|
||||
}
|
||||
contact.phoneNumbers = phoneNumbers.map {
|
||||
CNLabeledValue(label: CNLabelPhoneNumberMobile, value: CNPhoneNumber(stringValue: $0))
|
||||
}
|
||||
contact.emailAddresses = emails.map {
|
||||
CNLabeledValue(label: CNLabelHome, value: $0 as NSString)
|
||||
}
|
||||
|
||||
let save = CNSaveRequest()
|
||||
save.add(contact, toContainerWithIdentifier: nil)
|
||||
try store.execute(save)
|
||||
|
||||
let persisted: CNContact
|
||||
if !contact.identifier.isEmpty {
|
||||
persisted = try store.unifiedContact(
|
||||
withIdentifier: contact.identifier,
|
||||
keysToFetch: Self.payloadKeys)
|
||||
} else {
|
||||
persisted = contact
|
||||
}
|
||||
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; the caller should instruct the user to grant permission.
|
||||
// Prompts block the invoke and lead to timeouts in headless flows.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||
(values ?? [])
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.map { lowercased ? $0.lowercased() : $0 }
|
||||
}
|
||||
|
||||
private static func findExistingContact(
|
||||
store: CNContactStore,
|
||||
phoneNumbers: [String],
|
||||
emails: [String]) throws -> CNContact?
|
||||
{
|
||||
if phoneNumbers.isEmpty && emails.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
var matches: [CNContact] = []
|
||||
|
||||
for phone in phoneNumbers {
|
||||
let predicate = CNContact.predicateForContacts(matching: CNPhoneNumber(stringValue: phone))
|
||||
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||
matches.append(contentsOf: contacts)
|
||||
}
|
||||
|
||||
for email in emails {
|
||||
let predicate = CNContact.predicateForContacts(matchingEmailAddress: email)
|
||||
let contacts = try store.unifiedContacts(matching: predicate, keysToFetch: Self.payloadKeys)
|
||||
matches.append(contentsOf: contacts)
|
||||
}
|
||||
|
||||
return Self.matchContacts(contacts: matches, phoneNumbers: phoneNumbers, emails: emails)
|
||||
}
|
||||
|
||||
private static func matchContacts(
|
||||
contacts: [CNContact],
|
||||
phoneNumbers: [String],
|
||||
emails: [String]) -> CNContact?
|
||||
{
|
||||
let normalizedPhones = Set(phoneNumbers.map { normalizePhone($0) }.filter { !$0.isEmpty })
|
||||
let normalizedEmails = Set(emails.map { $0.lowercased() }.filter { !$0.isEmpty })
|
||||
var seen = Set<String>()
|
||||
|
||||
for contact in contacts {
|
||||
guard seen.insert(contact.identifier).inserted else { continue }
|
||||
let contactPhones = Set(contact.phoneNumbers.map { normalizePhone($0.value.stringValue) })
|
||||
let contactEmails = Set(contact.emailAddresses.map { String($0.value).lowercased() })
|
||||
|
||||
if !normalizedPhones.isEmpty, !contactPhones.isDisjoint(with: normalizedPhones) {
|
||||
return contact
|
||||
}
|
||||
if !normalizedEmails.isEmpty, !contactEmails.isDisjoint(with: normalizedEmails) {
|
||||
return contact
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func normalizePhone(_ phone: String) -> String {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let digits = trimmed.unicodeScalars.filter { CharacterSet.decimalDigits.contains($0) }
|
||||
let normalized = String(String.UnicodeScalarView(digits))
|
||||
return normalized.isEmpty ? trimmed : normalized
|
||||
}
|
||||
|
||||
private static func payload(from contact: CNContact) -> OpenClawContactPayload {
|
||||
OpenClawContactPayload(
|
||||
identifier: contact.identifier,
|
||||
displayName: CNContactFormatter.string(from: contact, style: .fullName)
|
||||
?? "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
givenName: contact.givenName,
|
||||
familyName: contact.familyName,
|
||||
organizationName: contact.organizationName,
|
||||
phoneNumbers: contact.phoneNumbers.map { $0.value.stringValue },
|
||||
emails: contact.emailAddresses.map { String($0.value) })
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
|
||||
matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
|
||||
}
|
||||
#endif
|
||||
}
|
||||
87
apps/ios/Sources/Device/DeviceStatusService.swift
Normal file
87
apps/ios/Sources/Device/DeviceStatusService.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
final class DeviceStatusService: DeviceStatusServicing {
|
||||
private let networkStatus: NetworkStatusService
|
||||
|
||||
init(networkStatus: NetworkStatusService = NetworkStatusService()) {
|
||||
self.networkStatus = networkStatus
|
||||
}
|
||||
|
||||
func status() async throws -> OpenClawDeviceStatusPayload {
|
||||
let battery = self.batteryStatus()
|
||||
let thermal = self.thermalStatus()
|
||||
let storage = self.storageStatus()
|
||||
let network = await self.networkStatus.currentStatus()
|
||||
let uptime = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
return OpenClawDeviceStatusPayload(
|
||||
battery: battery,
|
||||
thermal: thermal,
|
||||
storage: storage,
|
||||
network: network,
|
||||
uptimeSeconds: uptime)
|
||||
}
|
||||
|
||||
func info() -> OpenClawDeviceInfoPayload {
|
||||
let device = UIDevice.current
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
||||
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
|
||||
let locale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
return OpenClawDeviceInfoPayload(
|
||||
deviceName: device.name,
|
||||
modelIdentifier: Self.modelIdentifier(),
|
||||
systemName: device.systemName,
|
||||
systemVersion: device.systemVersion,
|
||||
appVersion: appVersion,
|
||||
appBuild: appBuild,
|
||||
locale: locale)
|
||||
}
|
||||
|
||||
private func batteryStatus() -> OpenClawBatteryStatusPayload {
|
||||
let device = UIDevice.current
|
||||
device.isBatteryMonitoringEnabled = true
|
||||
let level = device.batteryLevel >= 0 ? Double(device.batteryLevel) : nil
|
||||
let state: OpenClawBatteryState = switch device.batteryState {
|
||||
case .charging: .charging
|
||||
case .full: .full
|
||||
case .unplugged: .unplugged
|
||||
case .unknown: .unknown
|
||||
@unknown default: .unknown
|
||||
}
|
||||
return OpenClawBatteryStatusPayload(
|
||||
level: level,
|
||||
state: state,
|
||||
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled)
|
||||
}
|
||||
|
||||
private func thermalStatus() -> OpenClawThermalStatusPayload {
|
||||
let state: OpenClawThermalState = switch ProcessInfo.processInfo.thermalState {
|
||||
case .nominal: .nominal
|
||||
case .fair: .fair
|
||||
case .serious: .serious
|
||||
case .critical: .critical
|
||||
@unknown default: .nominal
|
||||
}
|
||||
return OpenClawThermalStatusPayload(state: state)
|
||||
}
|
||||
|
||||
private func storageStatus() -> OpenClawStorageStatusPayload {
|
||||
let attrs = (try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())) ?? [:]
|
||||
let total = (attrs[.systemSize] as? NSNumber)?.int64Value ?? 0
|
||||
let free = (attrs[.systemFreeSize] as? NSNumber)?.int64Value ?? 0
|
||||
let used = max(0, total - free)
|
||||
return OpenClawStorageStatusPayload(totalBytes: total, freeBytes: free, usedBytes: used)
|
||||
}
|
||||
|
||||
private static func modelIdentifier() -> String {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
|
||||
String(bytes: ptr.prefix { $0 != 0 }, encoding: .utf8)
|
||||
}
|
||||
let trimmed = machine?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
}
|
||||
69
apps/ios/Sources/Device/NetworkStatusService.swift
Normal file
69
apps/ios/Sources/Device/NetworkStatusService.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OpenClawKit
|
||||
|
||||
final class NetworkStatusService: @unchecked Sendable {
|
||||
func currentStatus(timeoutMs: Int = 1500) async -> OpenClawNetworkStatusPayload {
|
||||
await withCheckedContinuation { cont in
|
||||
let monitor = NWPathMonitor()
|
||||
let queue = DispatchQueue(label: "bot.molt.ios.network-status")
|
||||
let state = NetworkStatusState()
|
||||
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard state.markCompleted() else { return }
|
||||
monitor.cancel()
|
||||
cont.resume(returning: Self.payload(from: path))
|
||||
}
|
||||
|
||||
monitor.start(queue: queue)
|
||||
|
||||
queue.asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
|
||||
guard state.markCompleted() else { return }
|
||||
monitor.cancel()
|
||||
cont.resume(returning: Self.fallbackPayload())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func payload(from path: NWPath) -> OpenClawNetworkStatusPayload {
|
||||
let status: OpenClawNetworkPathStatus = switch path.status {
|
||||
case .satisfied: .satisfied
|
||||
case .requiresConnection: .requiresConnection
|
||||
case .unsatisfied: .unsatisfied
|
||||
@unknown default: .unsatisfied
|
||||
}
|
||||
|
||||
var interfaces: [OpenClawNetworkInterfaceType] = []
|
||||
if path.usesInterfaceType(.wifi) { interfaces.append(.wifi) }
|
||||
if path.usesInterfaceType(.cellular) { interfaces.append(.cellular) }
|
||||
if path.usesInterfaceType(.wiredEthernet) { interfaces.append(.wired) }
|
||||
if interfaces.isEmpty { interfaces.append(.other) }
|
||||
|
||||
return OpenClawNetworkStatusPayload(
|
||||
status: status,
|
||||
isExpensive: path.isExpensive,
|
||||
isConstrained: path.isConstrained,
|
||||
interfaces: interfaces)
|
||||
}
|
||||
|
||||
private static func fallbackPayload() -> OpenClawNetworkStatusPayload {
|
||||
OpenClawNetworkStatusPayload(
|
||||
status: .unsatisfied,
|
||||
isExpensive: false,
|
||||
isConstrained: false,
|
||||
interfaces: [.other])
|
||||
}
|
||||
}
|
||||
|
||||
private final class NetworkStatusState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var completed = false
|
||||
|
||||
func markCompleted() -> Bool {
|
||||
self.lock.lock()
|
||||
defer { self.lock.unlock() }
|
||||
if self.completed { return false }
|
||||
self.completed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal file
48
apps/ios/Sources/Device/NodeDisplayName.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
enum NodeDisplayName {
|
||||
private static let genericNames: Set<String> = ["iOS Node", "iPhone Node", "iPad Node"]
|
||||
|
||||
static func isGeneric(_ name: String) -> Bool {
|
||||
Self.genericNames.contains(name)
|
||||
}
|
||||
|
||||
static func defaultValue(for interfaceIdiom: UIUserInterfaceIdiom) -> String {
|
||||
switch interfaceIdiom {
|
||||
case .phone:
|
||||
return "iPhone Node"
|
||||
case .pad:
|
||||
return "iPad Node"
|
||||
default:
|
||||
return "iOS Node"
|
||||
}
|
||||
}
|
||||
|
||||
static func resolve(
|
||||
existing: String?,
|
||||
deviceName: String,
|
||||
interfaceIdiom: UIUserInterfaceIdiom
|
||||
) -> String {
|
||||
let trimmedExisting = existing?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedExisting.isEmpty, !Self.isGeneric(trimmedExisting) {
|
||||
return trimmedExisting
|
||||
}
|
||||
|
||||
let trimmedDevice = deviceName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let normalized = Self.normalizedDeviceName(trimmedDevice) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return Self.defaultValue(for: interfaceIdiom)
|
||||
}
|
||||
|
||||
private static func normalizedDeviceName(_ deviceName: String) -> String? {
|
||||
guard !deviceName.isEmpty else { return nil }
|
||||
let lower = deviceName.lowercased()
|
||||
if lower.contains("iphone") || lower.contains("ipad") || lower.contains("ios") {
|
||||
return deviceName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
27
apps/ios/Sources/Gateway/GatewayConnectConfig.swift
Normal file
27
apps/ios/Sources/Gateway/GatewayConnectConfig.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
/// Single source of truth for "how we connect" to the current gateway.
|
||||
///
|
||||
/// The iOS app maintains two WebSocket sessions to the same gateway:
|
||||
/// - a `role=node` session for device capabilities (`node.invoke.*`)
|
||||
/// - a `role=operator` session for chat/talk/config (`chat.*`, `talk.*`, etc.)
|
||||
///
|
||||
/// Both sessions should derive all connection inputs from this config so we
|
||||
/// don't accidentally persist gateway-scoped state under different keys.
|
||||
struct GatewayConnectConfig: Sendable {
|
||||
let url: URL
|
||||
let stableID: String
|
||||
let tls: GatewayTLSParams?
|
||||
let token: String?
|
||||
let password: String?
|
||||
let nodeOptions: GatewayConnectOptions
|
||||
|
||||
/// Stable, non-empty identifier used for gateway-scoped persistence keys.
|
||||
/// If the caller doesn't provide a stableID, fall back to URL identity.
|
||||
var effectiveStableID: String {
|
||||
let trimmed = self.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return self.url.absoluteString }
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import OpenClawKit
|
||||
import Darwin
|
||||
import AVFoundation
|
||||
import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import Photos
|
||||
import ReplayKit
|
||||
import Speech
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@@ -42,8 +49,10 @@ final class GatewayConnectionController {
|
||||
self.discovery.stop()
|
||||
case .active, .inactive:
|
||||
self.discovery.start()
|
||||
self.attemptAutoReconnectIfNeeded()
|
||||
@unknown default:
|
||||
self.discovery.start()
|
||||
self.attemptAutoReconnectIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +69,11 @@ final class GatewayConnectionController {
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true,
|
||||
stableID: gateway.stableID)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
@@ -74,13 +88,24 @@ final class GatewayConnectionController {
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let stableID = self.manualStableID(host: host, port: port)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: useTLS)
|
||||
let resolvedUseTLS = useTLS
|
||||
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||
else { return }
|
||||
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true,
|
||||
stableID: stableID)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
@@ -90,6 +115,38 @@ final class GatewayConnectionController {
|
||||
password: password)
|
||||
}
|
||||
|
||||
func connectLastKnown() async {
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let resolvedUseTLS = last.useTLS
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: last.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: last.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
if resolvedUseTLS != last.useTLS {
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: resolvedUseTLS,
|
||||
stableID: last.stableID)
|
||||
}
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: last.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
private func updateFromDiscovery() {
|
||||
let newGateways = self.discovery.gateways
|
||||
self.gateways = newGateways
|
||||
@@ -119,6 +176,7 @@ final class GatewayConnectionController {
|
||||
guard appModel.gatewayServerName == nil else { return }
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
guard defaults.bool(forKey: "gateway.autoconnect") else { return }
|
||||
let manualEnabled = defaults.bool(forKey: "gateway.manual.enabled")
|
||||
|
||||
let instanceId = defaults.string(forKey: "node.instanceId")?
|
||||
@@ -134,11 +192,19 @@ final class GatewayConnectionController {
|
||||
guard !manualHost.isEmpty else { return }
|
||||
|
||||
let manualPort = defaults.integer(forKey: "gateway.manual.port")
|
||||
let resolvedPort = manualPort > 0 ? manualPort : 18789
|
||||
let manualTLS = defaults.bool(forKey: "gateway.manual.tls")
|
||||
let resolvedUseTLS = manualTLS || self.shouldForceTLS(host: manualHost)
|
||||
guard let resolvedPort = self.resolveManualPort(
|
||||
host: manualHost,
|
||||
port: manualPort,
|
||||
useTLS: resolvedUseTLS)
|
||||
else { return }
|
||||
|
||||
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(stableID: stableID, tlsEnabled: manualTLS)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: manualHost))
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: manualHost,
|
||||
@@ -156,30 +222,80 @@ final class GatewayConnectionController {
|
||||
return
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: lastKnown.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: lastKnown.host,
|
||||
port: lastKnown.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: lastKnown.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lastDiscoveredStableID = defaults.string(forKey: "gateway.lastDiscoveredStableID")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
let candidates = [preferredStableID, lastDiscoveredStableID].filter { !$0.isEmpty }
|
||||
guard let targetStableID = candidates.first(where: { id in
|
||||
if let targetStableID = candidates.first(where: { id in
|
||||
self.gateways.contains(where: { $0.stableID == id })
|
||||
}) else { return }
|
||||
}) {
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
if self.gateways.count == 1, let gateway = self.gateways.first {
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptAutoReconnectIfNeeded() {
|
||||
guard let appModel = self.appModel else { return }
|
||||
guard appModel.gatewayAutoReconnectEnabled else { return }
|
||||
// Avoid starting duplicate connect loops while a prior config is active.
|
||||
guard appModel.activeGatewayConnectConfig == nil else { return }
|
||||
guard UserDefaults.standard.bool(forKey: "gateway.autoconnect") else { return }
|
||||
self.didAutoConnect = false
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
private func updateLastDiscoveredGateway(from gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
|
||||
@@ -205,20 +321,21 @@ final class GatewayConnectionController {
|
||||
password: String?)
|
||||
{
|
||||
guard let appModel else { return }
|
||||
let connectOptions = self.makeConnectOptions()
|
||||
let connectOptions = self.makeConnectOptions(stableID: gatewayStableID)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { [weak appModel] in
|
||||
guard let appModel else { return }
|
||||
await MainActor.run {
|
||||
appModel.gatewayStatusText = "Connecting…"
|
||||
}
|
||||
appModel.connectToGateway(
|
||||
let cfg = GatewayConnectConfig(
|
||||
url: url,
|
||||
gatewayStableID: gatewayStableID,
|
||||
stableID: gatewayStableID,
|
||||
tls: tls,
|
||||
token: token,
|
||||
password: password,
|
||||
connectOptions: connectOptions)
|
||||
nodeOptions: connectOptions)
|
||||
appModel.applyGatewayConnectConfig(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,13 +354,17 @@ final class GatewayConnectionController {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveManualTLSParams(stableID: String, tlsEnabled: Bool) -> GatewayTLSParams? {
|
||||
private func resolveManualTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
allowTOFUReset: Bool = false) -> GatewayTLSParams?
|
||||
{
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if tlsEnabled || stored != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil,
|
||||
allowTOFU: stored == nil || allowTOFUReset,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
@@ -251,12 +372,12 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
}
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -269,38 +390,69 @@ final class GatewayConnectionController {
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func shouldForceTLS(host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.isEmpty { return false }
|
||||
return trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.")
|
||||
}
|
||||
|
||||
private func manualStableID(host: String, port: Int) -> String {
|
||||
"manual|\(host.lowercased())|\(port)"
|
||||
}
|
||||
|
||||
private func makeConnectOptions() -> GatewayConnectOptions {
|
||||
private func makeConnectOptions(stableID: String?) -> GatewayConnectOptions {
|
||||
let defaults = UserDefaults.standard
|
||||
let displayName = self.resolvedDisplayName(defaults: defaults)
|
||||
let resolvedClientId = self.resolvedClientId(defaults: defaults, stableID: stableID)
|
||||
|
||||
return GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: self.currentCaps(),
|
||||
commands: self.currentCommands(),
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
permissions: self.currentPermissions(),
|
||||
clientId: resolvedClientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: displayName)
|
||||
}
|
||||
|
||||
private func resolvedClientId(defaults: UserDefaults, stableID: String?) -> String {
|
||||
if let stableID,
|
||||
let override = GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) {
|
||||
return override
|
||||
}
|
||||
let manualClientId = defaults.string(forKey: "gateway.manual.clientId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if manualClientId?.isEmpty == false {
|
||||
return manualClientId!
|
||||
}
|
||||
return "openclaw-ios"
|
||||
}
|
||||
|
||||
private func resolveManualPort(host: String, port: Int, useTLS: Bool) -> Int? {
|
||||
if port > 0 {
|
||||
return port <= 65535 ? port : nil
|
||||
}
|
||||
let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedHost.isEmpty else { return nil }
|
||||
if useTLS && self.shouldForceTLS(host: trimmedHost) {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}
|
||||
|
||||
private func resolvedDisplayName(defaults: UserDefaults) -> String {
|
||||
let key = "node.displayName"
|
||||
let existing = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !existing.isEmpty, existing != "iOS Node" { return existing }
|
||||
|
||||
let deviceName = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidate = deviceName.isEmpty ? "iOS Node" : deviceName
|
||||
|
||||
if existing.isEmpty || existing == "iOS Node" {
|
||||
defaults.set(candidate, forKey: key)
|
||||
let existingRaw = defaults.string(forKey: key)
|
||||
let resolved = NodeDisplayName.resolve(
|
||||
existing: existingRaw,
|
||||
deviceName: UIDevice.current.name,
|
||||
interfaceIdiom: UIDevice.current.userInterfaceIdiom)
|
||||
let existing = existingRaw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if existing.isEmpty || NodeDisplayName.isGeneric(existing) {
|
||||
defaults.set(resolved, forKey: key)
|
||||
}
|
||||
|
||||
return candidate
|
||||
return resolved
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
@@ -320,6 +472,15 @@ final class GatewayConnectionController {
|
||||
let locationMode = OpenClawLocationMode(rawValue: locationModeRaw) ?? .off
|
||||
if locationMode != .off { caps.append(OpenClawCapability.location.rawValue) }
|
||||
|
||||
caps.append(OpenClawCapability.device.rawValue)
|
||||
caps.append(OpenClawCapability.photos.rawValue)
|
||||
caps.append(OpenClawCapability.contacts.rawValue)
|
||||
caps.append(OpenClawCapability.calendar.rawValue)
|
||||
caps.append(OpenClawCapability.reminders.rawValue)
|
||||
if Self.motionAvailable() {
|
||||
caps.append(OpenClawCapability.motion.rawValue)
|
||||
}
|
||||
|
||||
return caps
|
||||
}
|
||||
|
||||
@@ -335,10 +496,11 @@ final class GatewayConnectionController {
|
||||
OpenClawCanvasA2UICommand.reset.rawValue,
|
||||
OpenClawScreenCommand.record.rawValue,
|
||||
OpenClawSystemCommand.notify.rawValue,
|
||||
OpenClawSystemCommand.which.rawValue,
|
||||
OpenClawSystemCommand.run.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsGet.rawValue,
|
||||
OpenClawSystemCommand.execApprovalsSet.rawValue,
|
||||
OpenClawChatCommand.push.rawValue,
|
||||
OpenClawTalkCommand.pttStart.rawValue,
|
||||
OpenClawTalkCommand.pttStop.rawValue,
|
||||
OpenClawTalkCommand.pttCancel.rawValue,
|
||||
OpenClawTalkCommand.pttOnce.rawValue,
|
||||
]
|
||||
|
||||
let caps = Set(self.currentCaps())
|
||||
@@ -350,10 +512,76 @@ final class GatewayConnectionController {
|
||||
if caps.contains(OpenClawCapability.location.rawValue) {
|
||||
commands.append(OpenClawLocationCommand.get.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.device.rawValue) {
|
||||
commands.append(OpenClawDeviceCommand.status.rawValue)
|
||||
commands.append(OpenClawDeviceCommand.info.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.photos.rawValue) {
|
||||
commands.append(OpenClawPhotosCommand.latest.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.contacts.rawValue) {
|
||||
commands.append(OpenClawContactsCommand.search.rawValue)
|
||||
commands.append(OpenClawContactsCommand.add.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.calendar.rawValue) {
|
||||
commands.append(OpenClawCalendarCommand.events.rawValue)
|
||||
commands.append(OpenClawCalendarCommand.add.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.reminders.rawValue) {
|
||||
commands.append(OpenClawRemindersCommand.list.rawValue)
|
||||
commands.append(OpenClawRemindersCommand.add.rawValue)
|
||||
}
|
||||
if caps.contains(OpenClawCapability.motion.rawValue) {
|
||||
commands.append(OpenClawMotionCommand.activity.rawValue)
|
||||
commands.append(OpenClawMotionCommand.pedometer.rawValue)
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentPermissions() -> [String: Bool] {
|
||||
var permissions: [String: Bool] = [:]
|
||||
permissions["camera"] = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
permissions["microphone"] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
permissions["speechRecognition"] = SFSpeechRecognizer.authorizationStatus() == .authorized
|
||||
permissions["location"] = Self.isLocationAuthorized(
|
||||
status: CLLocationManager().authorizationStatus)
|
||||
&& CLLocationManager.locationServicesEnabled()
|
||||
permissions["screenRecording"] = RPScreenRecorder.shared().isAvailable
|
||||
|
||||
let photoStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
permissions["photos"] = photoStatus == .authorized || photoStatus == .limited
|
||||
let contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
permissions["contacts"] = contactsStatus == .authorized || contactsStatus == .limited
|
||||
|
||||
let calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
permissions["calendar"] =
|
||||
calendarStatus == .authorized || calendarStatus == .fullAccess || calendarStatus == .writeOnly
|
||||
let remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
permissions["reminders"] =
|
||||
remindersStatus == .authorized || remindersStatus == .fullAccess || remindersStatus == .writeOnly
|
||||
|
||||
let motionStatus = CMMotionActivityManager.authorizationStatus()
|
||||
let pedometerStatus = CMPedometer.authorizationStatus()
|
||||
permissions["motion"] =
|
||||
motionStatus == .authorized || pedometerStatus == .authorized
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
private static func isLocationAuthorized(status: CLAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func motionAvailable() -> Bool {
|
||||
CMMotionActivityManager.isActivityAvailable() || CMPedometer.isStepCountingAvailable()
|
||||
}
|
||||
|
||||
private func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let name = switch UIDevice.current.userInterfaceIdiom {
|
||||
@@ -407,6 +635,10 @@ extension GatewayConnectionController {
|
||||
self.currentCommands()
|
||||
}
|
||||
|
||||
func _test_currentPermissions() -> [String: Bool] {
|
||||
self.currentPermissions()
|
||||
}
|
||||
|
||||
func _test_platformString() -> String {
|
||||
self.platformString()
|
||||
}
|
||||
|
||||
85
apps/ios/Sources/Gateway/GatewayHealthMonitor.swift
Normal file
85
apps/ios/Sources/Gateway/GatewayHealthMonitor.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class GatewayHealthMonitor {
|
||||
struct Config: Sendable {
|
||||
var intervalSeconds: Double
|
||||
var timeoutSeconds: Double
|
||||
var maxFailures: Int
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private let sleep: @Sendable (UInt64) async -> Void
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
config: Config = Config(intervalSeconds: 15, timeoutSeconds: 5, maxFailures: 3),
|
||||
sleep: @escaping @Sendable (UInt64) async -> Void = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
) {
|
||||
self.config = config
|
||||
self.sleep = sleep
|
||||
}
|
||||
|
||||
func start(
|
||||
check: @escaping @Sendable () async throws -> Bool,
|
||||
onFailure: @escaping @Sendable (_ failureCount: Int) async -> Void)
|
||||
{
|
||||
self.stop()
|
||||
let config = self.config
|
||||
let sleep = self.sleep
|
||||
self.task = Task { @MainActor in
|
||||
var failures = 0
|
||||
while !Task.isCancelled {
|
||||
let ok = await Self.runCheck(check: check, timeoutSeconds: config.timeoutSeconds)
|
||||
if ok {
|
||||
failures = 0
|
||||
} else {
|
||||
failures += 1
|
||||
if failures >= max(1, config.maxFailures) {
|
||||
await onFailure(failures)
|
||||
failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
if Task.isCancelled { break }
|
||||
let interval = max(0.0, config.intervalSeconds)
|
||||
let nanos = UInt64(interval * 1_000_000_000)
|
||||
if nanos > 0 {
|
||||
await sleep(nanos)
|
||||
} else {
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
}
|
||||
|
||||
private static func runCheck(
|
||||
check: @escaping @Sendable () async throws -> Bool,
|
||||
timeoutSeconds: Double) async -> Bool
|
||||
{
|
||||
let timeout = max(0.0, timeoutSeconds)
|
||||
if timeout == 0 {
|
||||
return (try? await check()) ?? false
|
||||
}
|
||||
do {
|
||||
let timeoutError = NSError(
|
||||
domain: "GatewayHealthMonitor",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "health check timed out"])
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: timeout,
|
||||
onTimeout: { timeoutError },
|
||||
operation: check)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
@@ -12,6 +13,12 @@ enum GatewaySettingsStore {
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
|
||||
private static let lastGatewayStableIDDefaultsKey = "gateway.last.stableID"
|
||||
private static let clientIdOverrideDefaultsPrefix = "gateway.clientIdOverride."
|
||||
private static let selectedAgentDefaultsPrefix = "gateway.selectedAgentId."
|
||||
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
@@ -107,6 +114,71 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
|
||||
let defaults = UserDefaults.standard
|
||||
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
|
||||
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return nil }
|
||||
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||
let value = UserDefaults.standard.string(forKey: key)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveGatewayClientIdOverride(stableID: String, clientId: String?) {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return }
|
||||
let key = self.clientIdOverrideDefaultsPrefix + trimmedID
|
||||
let trimmedClientId = clientId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmedClientId.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.set(trimmedClientId, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
static func loadGatewaySelectedAgentId(stableID: String) -> String? {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return nil }
|
||||
let key = self.selectedAgentDefaultsPrefix + trimmedID
|
||||
let value = UserDefaults.standard.string(forKey: key)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveGatewaySelectedAgentId(stableID: String, agentId: String?) {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return }
|
||||
let key = self.selectedAgentDefaultsPrefix + trimmedID
|
||||
let trimmedAgentId = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmedAgentId.isEmpty {
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
} else {
|
||||
UserDefaults.standard.set(trimmedAgentId, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
private static func gatewayTokenAccount(instanceId: String) -> String {
|
||||
"gateway-token.\(instanceId)"
|
||||
}
|
||||
@@ -175,3 +247,101 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum GatewayDiagnostics {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw.ios", category: "GatewayDiag")
|
||||
private static let queue = DispatchQueue(label: "ai.openclaw.gateway.diagnostics")
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
private static var fileURL: URL? {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("openclaw-gateway.log")
|
||||
}
|
||||
|
||||
private static func truncateLogIfNeeded(url: URL) {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||
let sizeNumber = attrs[.size] as? NSNumber
|
||||
else { return }
|
||||
let size = sizeNumber.int64Value
|
||||
guard size > self.maxLogBytes else { return }
|
||||
|
||||
do {
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
|
||||
let start = max(Int64(0), size - self.keepLogBytes)
|
||||
try handle.seek(toOffset: UInt64(start))
|
||||
var tail = try handle.readToEnd() ?? Data()
|
||||
|
||||
// If we truncated mid-line, drop the first partial line so logs remain readable.
|
||||
if start > 0, let nl = tail.firstIndex(of: 10) {
|
||||
let next = tail.index(after: nl)
|
||||
if next < tail.endIndex {
|
||||
tail = tail.suffix(from: next)
|
||||
} else {
|
||||
tail = Data()
|
||||
}
|
||||
}
|
||||
|
||||
try tail.write(to: url, options: .atomic)
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
private static func appendToLog(url: URL, data: Data) {
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
if let handle = try? FileHandle(forWritingTo: url) {
|
||||
defer { try? handle.close() }
|
||||
_ = try? handle.seekToEnd()
|
||||
try? handle.write(contentsOf: data)
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: url, options: .atomic)
|
||||
}
|
||||
}
|
||||
|
||||
static func bootstrap() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
self.appendToLog(url: url, data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func log(_ message: String) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let line = "[\(timestamp)] \(message)"
|
||||
logger.info("\(line, privacy: .public)")
|
||||
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.logWritesSinceCheck += 1
|
||||
if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites {
|
||||
self.logWritesSinceCheck = 0
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
}
|
||||
let entry = line + "\n"
|
||||
if let data = entry.data(using: .utf8) {
|
||||
self.appendToLog(url: url, data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func reset() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.6</string>
|
||||
<string>2026.2.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
164
apps/ios/Sources/Media/PhotoLibraryService.swift
Normal file
164
apps/ios/Sources/Media/PhotoLibraryService.swift
Normal file
@@ -0,0 +1,164 @@
|
||||
import Foundation
|
||||
import Photos
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
final class PhotoLibraryService: PhotosServicing {
|
||||
// The gateway WebSocket has a max payload size; returning large base64 blobs
|
||||
// can cause the gateway to close the connection. Keep photo payloads small
|
||||
// enough to safely fit in a single RPC frame.
|
||||
//
|
||||
// This is a transport constraint (not a security policy). If callers need
|
||||
// full-resolution media, we should switch to an HTTP media handle flow.
|
||||
private static let maxTotalBase64Chars = 340 * 1024
|
||||
private static let maxPerPhotoBase64Chars = 300 * 1024
|
||||
|
||||
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload {
|
||||
let status = await Self.ensureAuthorization()
|
||||
guard status == .authorized || status == .limited else {
|
||||
throw NSError(domain: "Photos", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "PHOTOS_PERMISSION_REQUIRED: grant Photos permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 1, 20))
|
||||
let fetchOptions = PHFetchOptions()
|
||||
fetchOptions.fetchLimit = limit
|
||||
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
let assets = PHAsset.fetchAssets(with: .image, options: fetchOptions)
|
||||
|
||||
var results: [OpenClawPhotoPayload] = []
|
||||
var remainingBudget = Self.maxTotalBase64Chars
|
||||
let maxWidth = params.maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? 1600
|
||||
let quality = params.quality.map { max(0.1, min(1.0, $0)) } ?? 0.85
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
||||
assets.enumerateObjects { asset, _, stop in
|
||||
if results.count >= limit { stop.pointee = true; return }
|
||||
if let payload = try? Self.renderAsset(
|
||||
asset,
|
||||
maxWidth: maxWidth,
|
||||
quality: quality,
|
||||
formatter: formatter)
|
||||
{
|
||||
// Keep the entire response under the gateway WS max payload.
|
||||
if payload.base64.count > remainingBudget {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
remainingBudget -= payload.base64.count
|
||||
results.append(payload)
|
||||
}
|
||||
}
|
||||
|
||||
return OpenClawPhotosLatestPayload(photos: results)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization() async -> PHAuthorizationStatus {
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
}
|
||||
|
||||
private static func renderAsset(
|
||||
_ asset: PHAsset,
|
||||
maxWidth: Int,
|
||||
quality: Double,
|
||||
formatter: ISO8601DateFormatter) throws -> OpenClawPhotoPayload
|
||||
{
|
||||
let manager = PHImageManager.default()
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
options.isNetworkAccessAllowed = true
|
||||
options.deliveryMode = .highQualityFormat
|
||||
|
||||
let targetSize: CGSize = {
|
||||
guard maxWidth > 0 else { return PHImageManagerMaximumSize }
|
||||
let aspect = CGFloat(asset.pixelHeight) / CGFloat(max(1, asset.pixelWidth))
|
||||
let width = CGFloat(maxWidth)
|
||||
return CGSize(width: width, height: width * aspect)
|
||||
}()
|
||||
|
||||
var image: UIImage?
|
||||
manager.requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
contentMode: .aspectFit,
|
||||
options: options)
|
||||
{ result, _ in
|
||||
image = result
|
||||
}
|
||||
|
||||
guard let image else {
|
||||
throw NSError(domain: "Photos", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "photo load failed",
|
||||
])
|
||||
}
|
||||
|
||||
let (data, finalImage) = try encodeJpegUnderBudget(
|
||||
image: image,
|
||||
quality: quality,
|
||||
maxBase64Chars: maxPerPhotoBase64Chars)
|
||||
|
||||
let created = asset.creationDate.map { formatter.string(from: $0) }
|
||||
return OpenClawPhotoPayload(
|
||||
format: "jpeg",
|
||||
base64: data.base64EncodedString(),
|
||||
width: Int(finalImage.size.width),
|
||||
height: Int(finalImage.size.height),
|
||||
createdAt: created)
|
||||
}
|
||||
|
||||
private static func encodeJpegUnderBudget(
|
||||
image: UIImage,
|
||||
quality: Double,
|
||||
maxBase64Chars: Int) throws -> (Data, UIImage)
|
||||
{
|
||||
var currentImage = image
|
||||
var currentQuality = max(0.1, min(1.0, quality))
|
||||
|
||||
// Try lowering JPEG quality first, then downscale if needed.
|
||||
for _ in 0..<10 {
|
||||
guard let data = currentImage.jpegData(compressionQuality: currentQuality) else {
|
||||
throw NSError(domain: "Photos", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "photo encode failed",
|
||||
])
|
||||
}
|
||||
|
||||
let base64Len = ((data.count + 2) / 3) * 4
|
||||
if base64Len <= maxBase64Chars {
|
||||
return (data, currentImage)
|
||||
}
|
||||
|
||||
if currentQuality > 0.35 {
|
||||
currentQuality = max(0.25, currentQuality - 0.15)
|
||||
continue
|
||||
}
|
||||
|
||||
// Downscale by ~25% each step once quality is low.
|
||||
let newWidth = max(240, currentImage.size.width * 0.75)
|
||||
if newWidth >= currentImage.size.width {
|
||||
break
|
||||
}
|
||||
currentImage = resize(image: currentImage, targetWidth: newWidth)
|
||||
}
|
||||
|
||||
throw NSError(domain: "Photos", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "photo too large for gateway transport; try smaller maxWidth/quality",
|
||||
])
|
||||
}
|
||||
|
||||
private static func resize(image: UIImage, targetWidth: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
if size.width <= 0 || size.height <= 0 || targetWidth <= 0 {
|
||||
return image
|
||||
}
|
||||
let scale = targetWidth / size.width
|
||||
let targetSize = CGSize(width: targetWidth, height: max(1, size.height * scale))
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: targetSize, format: format)
|
||||
return renderer.image { _ in
|
||||
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
97
apps/ios/Sources/Model/NodeAppModel+Canvas.swift
Normal file
97
apps/ios/Sources/Model/NodeAppModel+Canvas.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
extension NodeAppModel {
|
||||
func _test_resolveA2UIHostURL() async -> String? {
|
||||
await self.resolveA2UIHostURL()
|
||||
}
|
||||
|
||||
func resolveA2UIHostURL() async -> String? {
|
||||
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
|
||||
if let host = base.host, Self.isLoopbackHost(host) {
|
||||
return nil
|
||||
}
|
||||
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
|
||||
}
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func showA2UIOnConnectIfNeeded() async {
|
||||
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
|
||||
await MainActor.run {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
return
|
||||
}
|
||||
let current = self.screen.urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if current.isEmpty || current == self.lastAutoA2uiURL {
|
||||
// Avoid navigating the WKWebView to an unreachable host: it leaves a persistent
|
||||
// "could not connect to the server" overlay even when the gateway is connected.
|
||||
if let url = URL(string: a2uiUrl),
|
||||
await Self.probeTCP(url: url, timeoutSeconds: 2.5)
|
||||
{
|
||||
self.screen.navigate(to: a2uiUrl)
|
||||
self.lastAutoA2uiURL = a2uiUrl
|
||||
} else {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showLocalCanvasOnDisconnect() {
|
||||
self.lastAutoA2uiURL = nil
|
||||
self.screen.showDefaultCanvas()
|
||||
}
|
||||
|
||||
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
||||
guard let host = url.host, !host.isEmpty else { return false }
|
||||
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
||||
guard portInt >= 1, portInt <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "a2ui.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
100
apps/ios/Sources/Motion/MotionService.swift
Normal file
100
apps/ios/Sources/Motion/MotionService.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import CoreMotion
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class MotionService: MotionServicing {
|
||||
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload {
|
||||
guard CMMotionActivityManager.isActivityAvailable() else {
|
||||
throw NSError(domain: "Motion", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "MOTION_UNAVAILABLE: activity not supported on this device",
|
||||
])
|
||||
}
|
||||
let auth = CMMotionActivityManager.authorizationStatus()
|
||||
guard auth == .authorized else {
|
||||
throw NSError(domain: "Motion", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission",
|
||||
])
|
||||
}
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let limit = max(1, min(params.limit ?? 200, 1000))
|
||||
|
||||
let manager = CMMotionActivityManager()
|
||||
let mapped = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawMotionActivityEntry], Error>) in
|
||||
manager.queryActivityStarting(from: start, to: end, to: OperationQueue()) { activity, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let sliced = Array((activity ?? []).suffix(limit))
|
||||
let entries = sliced.map { entry in
|
||||
OpenClawMotionActivityEntry(
|
||||
startISO: formatter.string(from: entry.startDate),
|
||||
endISO: formatter.string(from: end),
|
||||
confidence: Self.confidenceString(entry.confidence),
|
||||
isWalking: entry.walking,
|
||||
isRunning: entry.running,
|
||||
isCycling: entry.cycling,
|
||||
isAutomotive: entry.automotive,
|
||||
isStationary: entry.stationary,
|
||||
isUnknown: entry.unknown)
|
||||
}
|
||||
cont.resume(returning: entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return OpenClawMotionActivityPayload(activities: mapped)
|
||||
}
|
||||
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload {
|
||||
guard CMPedometer.isStepCountingAvailable() else {
|
||||
throw NSError(domain: "Motion", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "PEDOMETER_UNAVAILABLE: step counting not supported",
|
||||
])
|
||||
}
|
||||
let auth = CMPedometer.authorizationStatus()
|
||||
guard auth == .authorized else {
|
||||
throw NSError(domain: "Motion", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "MOTION_PERMISSION_REQUIRED: grant Motion & Fitness permission",
|
||||
])
|
||||
}
|
||||
|
||||
let (start, end) = Self.resolveRange(startISO: params.startISO, endISO: params.endISO)
|
||||
let pedometer = CMPedometer()
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<OpenClawPedometerPayload, Error>) in
|
||||
pedometer.queryPedometerData(from: start, to: end) { data, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let payload = OpenClawPedometerPayload(
|
||||
startISO: formatter.string(from: start),
|
||||
endISO: formatter.string(from: end),
|
||||
steps: data?.numberOfSteps.intValue,
|
||||
distanceMeters: data?.distance?.doubleValue,
|
||||
floorsAscended: data?.floorsAscended?.intValue,
|
||||
floorsDescended: data?.floorsDescended?.intValue)
|
||||
cont.resume(returning: payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
private static func resolveRange(startISO: String?, endISO: String?) -> (Date, Date) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let start = startISO.flatMap { formatter.date(from: $0) } ?? Calendar.current.startOfDay(for: Date())
|
||||
let end = endISO.flatMap { formatter.date(from: $0) } ?? Date()
|
||||
return (start, end)
|
||||
}
|
||||
|
||||
private static func confidenceString(_ confidence: CMMotionActivityConfidence) -> String {
|
||||
switch confidence {
|
||||
case .low: "low"
|
||||
case .medium: "medium"
|
||||
case .high: "high"
|
||||
@unknown default: "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
389
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
389
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
@@ -0,0 +1,389 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Text("Connect to your gateway to get started.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink("Auto detect") {
|
||||
AutoDetectStep()
|
||||
}
|
||||
NavigationLink("Manual entry") {
|
||||
ManualEntryStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AutoDetectStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Text("We’ll scan for gateways on your network and connect automatically when we find one.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: self.connectionStatusLines(),
|
||||
secondaryLine: self.connectStatusText)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Retry") {
|
||||
self.resetConnectionState()
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Auto detect")
|
||||
.onAppear { self.triggerAutoConnect() }
|
||||
.onChange(of: self.gatewayController.gateways) { _, _ in
|
||||
self.triggerAutoConnect()
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerAutoConnect() {
|
||||
guard self.appModel.gatewayServerName == nil else { return }
|
||||
guard self.connectingGatewayID == nil else { return }
|
||||
guard let candidate = self.autoCandidate() else { return }
|
||||
|
||||
self.connectingGatewayID = candidate.id
|
||||
Task {
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if !preferred.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if !lastDiscovered.isEmpty,
|
||||
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
|
||||
{
|
||||
return match
|
||||
}
|
||||
if self.gatewayController.gateways.count == 1 {
|
||||
return self.gatewayController.gateways.first
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func connectionStatusLines() -> [String] {
|
||||
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||
}
|
||||
|
||||
private func resetConnectionState() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectStatusText = nil
|
||||
self.connectingGatewayID = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManualEntryStep: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
@State private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPortText: String = ""
|
||||
@State private var manualUseTLS: Bool = true
|
||||
@State private var manualToken: String = ""
|
||||
@State private var manualPassword: String = ""
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var connectStatusText: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Setup code") {
|
||||
Text("Use /pair in your bot to get a setup code.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button("Apply setup code") {
|
||||
self.applySetupCode()
|
||||
}
|
||||
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
if let setupStatusText, !setupStatusText.isEmpty {
|
||||
Text(setupStatusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualUseTLS)
|
||||
|
||||
TextField("Gateway token", text: self.$manualToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway password", text: self.$manualPassword)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
Section("Connection status") {
|
||||
ConnectionStatusBox(
|
||||
statusLines: self.connectionStatusLines(),
|
||||
secondaryLine: self.connectStatusText)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button("Retry") {
|
||||
self.resetConnectionState()
|
||||
self.resetManualForm()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Manual entry")
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
|
||||
if let port = self.manualPortValue(), !(1...65535).contains(port) {
|
||||
self.connectStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.manual.enabled")
|
||||
defaults.set(host, forKey: "gateway.manual.host")
|
||||
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
|
||||
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
|
||||
|
||||
if let instanceId = defaults.string(forKey: "node.instanceId")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!instanceId.isEmpty
|
||||
{
|
||||
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedToken.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
|
||||
}
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
|
||||
}
|
||||
|
||||
self.connectingGatewayID = "manual"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualPortValue() ?? 0,
|
||||
useTLS: self.manualUseTLS)
|
||||
}
|
||||
|
||||
private func manualPortValue() -> Int? {
|
||||
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return Int(trimmed.filter { $0.isNumber })
|
||||
}
|
||||
|
||||
private func connectionStatusLines() -> [String] {
|
||||
ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
|
||||
}
|
||||
|
||||
private func resetConnectionState() {
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectStatusText = nil
|
||||
self.connectingGatewayID = nil
|
||||
}
|
||||
|
||||
private func resetManualForm() {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
self.manualHost = ""
|
||||
self.manualPortText = ""
|
||||
self.manualUseTLS = true
|
||||
self.manualToken = ""
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return
|
||||
}
|
||||
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return
|
||||
}
|
||||
|
||||
if let urlString = payload.url, let url = URL(string: urlString) {
|
||||
self.applyURL(url)
|
||||
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.manualHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let port = payload.port {
|
||||
self.manualPortText = String(port)
|
||||
} else {
|
||||
self.manualPortText = ""
|
||||
}
|
||||
if let tls = payload.tls {
|
||||
self.manualUseTLS = tls
|
||||
}
|
||||
} else if let url = URL(string: raw), url.scheme != nil {
|
||||
self.applyURL(url)
|
||||
} else {
|
||||
self.setupStatusText = "Setup code missing URL or host."
|
||||
return
|
||||
}
|
||||
|
||||
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
self.setupStatusText = "Setup code applied."
|
||||
}
|
||||
|
||||
private func applyURL(_ url: URL) {
|
||||
guard let host = url.host, !host.isEmpty else { return }
|
||||
self.manualHost = host
|
||||
if let port = url.port {
|
||||
self.manualPortText = String(port)
|
||||
} else {
|
||||
self.manualPortText = ""
|
||||
}
|
||||
let scheme = (url.scheme ?? "").lowercased()
|
||||
if scheme == "wss" || scheme == "https" {
|
||||
self.manualUseTLS = true
|
||||
} else if scheme == "ws" || scheme == "http" {
|
||||
self.manualUseTLS = false
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
let statusLines: [String]
|
||||
let secondaryLine: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.statusLines, id: \.self) { line in
|
||||
Text(line)
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let secondaryLine, !secondaryLine.isEmpty {
|
||||
Text(secondaryLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
|
||||
static func defaultLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController
|
||||
) -> [String] {
|
||||
var lines: [String] = [
|
||||
"gateway: \(appModel.gatewayStatusText)",
|
||||
"discovery: \(gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(appModel.gatewayServerName ?? "—")")
|
||||
lines.append("address: \(appModel.gatewayRemoteAddress ?? "—")")
|
||||
return lines
|
||||
}
|
||||
}
|
||||
165
apps/ios/Sources/Reminders/RemindersService.swift
Normal file
165
apps/ios/Sources/Reminders/RemindersService.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let limit = max(1, min(params.limit ?? 50, 500))
|
||||
let statusFilter = params.status ?? .incomplete
|
||||
|
||||
let predicate = store.predicateForReminders(in: nil)
|
||||
let payload = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<[OpenClawReminderPayload], Error>) in
|
||||
store.fetchReminders(matching: predicate) { items in
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let filtered = (items ?? []).filter { reminder in
|
||||
switch statusFilter {
|
||||
case .all:
|
||||
return true
|
||||
case .completed:
|
||||
return reminder.isCompleted
|
||||
case .incomplete:
|
||||
return !reminder.isCompleted
|
||||
}
|
||||
}
|
||||
let selected = Array(filtered.prefix(limit))
|
||||
let payload = selected.map { reminder in
|
||||
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
|
||||
return OpenClawReminderPayload(
|
||||
identifier: reminder.calendarItemIdentifier,
|
||||
title: reminder.title,
|
||||
dueISO: due.map { formatter.string(from: $0) },
|
||||
completed: reminder.isCompleted,
|
||||
listName: reminder.calendar.title)
|
||||
}
|
||||
cont.resume(returning: payload)
|
||||
}
|
||||
}
|
||||
|
||||
return OpenClawRemindersListPayload(reminders: payload)
|
||||
}
|
||||
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Reminders", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_INVALID: title required",
|
||||
])
|
||||
}
|
||||
|
||||
let reminder = EKReminder(eventStore: store)
|
||||
reminder.title = title
|
||||
if let notes = params.notes?.trimmingCharacters(in: .whitespacesAndNewlines), !notes.isEmpty {
|
||||
reminder.notes = notes
|
||||
}
|
||||
reminder.calendar = try Self.resolveList(
|
||||
store: store,
|
||||
listId: params.listId,
|
||||
listName: params.listName)
|
||||
|
||||
if let dueISO = params.dueISO?.trimmingCharacters(in: .whitespacesAndNewlines), !dueISO.isEmpty {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let dueDate = formatter.date(from: dueISO) else {
|
||||
throw NSError(domain: "Reminders", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_INVALID: dueISO must be ISO-8601",
|
||||
])
|
||||
}
|
||||
reminder.dueDateComponents = Calendar.current.dateComponents(
|
||||
[.year, .month, .day, .hour, .minute, .second],
|
||||
from: dueDate)
|
||||
}
|
||||
|
||||
try store.save(reminder, commit: true)
|
||||
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let due = reminder.dueDateComponents.flatMap { Calendar.current.date(from: $0) }
|
||||
let payload = OpenClawReminderPayload(
|
||||
identifier: reminder.calendarItemIdentifier,
|
||||
title: reminder.title,
|
||||
dueISO: due.map { formatter.string(from: $0) },
|
||||
completed: reminder.isCompleted,
|
||||
listName: reminder.calendar.title)
|
||||
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
listName: String?) throws -> EKCalendar
|
||||
{
|
||||
if let id = listId?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty,
|
||||
let calendar = store.calendar(withIdentifier: id)
|
||||
{
|
||||
return calendar
|
||||
}
|
||||
|
||||
if let title = listName?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty {
|
||||
if let calendar = store.calendars(for: .reminder).first(where: {
|
||||
$0.title.compare(title, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
|
||||
}) {
|
||||
return calendar
|
||||
}
|
||||
throw NSError(domain: "Reminders", code: 5, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no list named \(title)",
|
||||
])
|
||||
}
|
||||
|
||||
if let fallback = store.defaultCalendarForNewReminders() {
|
||||
return fallback
|
||||
}
|
||||
|
||||
throw NSError(domain: "Reminders", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_LIST_NOT_FOUND: no default list",
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,15 @@ struct RootCanvas: View {
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var didAutoOpenSettings: Bool = false
|
||||
|
||||
private enum PresentedSheet: Identifiable {
|
||||
case settings
|
||||
@@ -52,12 +58,14 @@ struct RootCanvas: View {
|
||||
SettingsTab()
|
||||
case .chat:
|
||||
ChatSheet(
|
||||
gateway: self.appModel.gatewaySession,
|
||||
gateway: self.appModel.operatorSession,
|
||||
sessionKey: self.appModel.mainSessionKey,
|
||||
agentName: self.appModel.activeAgentName,
|
||||
userAccent: self.appModel.seamColor)
|
||||
}
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
@@ -65,6 +73,13 @@ struct RootCanvas: View {
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
self.hasConnectedOnce = true
|
||||
}
|
||||
self.maybeAutoOpenSettings()
|
||||
}
|
||||
.onChange(of: self.voiceWake.lastTriggeredCommand) { _, newValue in
|
||||
guard let newValue else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -119,12 +134,33 @@ struct RootCanvas: View {
|
||||
let subtitle = self.appModel.gatewayServerName ?? self.appModel.gatewayRemoteAddress
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func shouldAutoOpenSettings() -> Bool {
|
||||
if self.appModel.gatewayServerName != nil { return false }
|
||||
if !self.hasConnectedOnce { return true }
|
||||
if !self.onboardingComplete { return true }
|
||||
return !self.hasExistingGatewayConfig()
|
||||
}
|
||||
|
||||
private func hasExistingGatewayConfig() -> Bool {
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return true }
|
||||
let manualHost = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return self.manualGatewayEnabled && !manualHost.isEmpty
|
||||
}
|
||||
|
||||
private func maybeAutoOpenSettings() {
|
||||
guard !self.didAutoOpenSettings else { return }
|
||||
guard self.shouldAutoOpenSettings() else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
}
|
||||
|
||||
private struct CanvasContent: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@State private var showGatewayActions: Bool = false
|
||||
var systemColorScheme: ColorScheme
|
||||
var gatewayStatus: StatusPill.GatewayState
|
||||
var voiceWakeEnabled: Bool
|
||||
@@ -182,7 +218,11 @@ private struct CanvasContent: View {
|
||||
activity: self.statusActivity,
|
||||
brighten: self.brightenButtons,
|
||||
onTap: {
|
||||
self.openSettings()
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.openSettings()
|
||||
}
|
||||
})
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
@@ -197,6 +237,21 @@ private struct CanvasContent: View {
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: self.$showGatewayActions,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
self.openSettings()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
@@ -248,6 +303,10 @@ private struct CanvasContent: View {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ struct RootTabs: View {
|
||||
@State private var selectedTab: Int = 0
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var showGatewayActions: Bool = false
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: self.$selectedTab) {
|
||||
@@ -27,7 +28,13 @@ struct RootTabs: View {
|
||||
gateway: self.gatewayStatus,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
activity: self.statusActivity,
|
||||
onTap: { self.selectedTab = 2 })
|
||||
onTap: {
|
||||
if self.gatewayStatus == .connected {
|
||||
self.showGatewayActions = true
|
||||
} else {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
})
|
||||
.padding(.leading, 10)
|
||||
.safeAreaPadding(.top, 10)
|
||||
}
|
||||
@@ -62,6 +69,21 @@ struct RootTabs: View {
|
||||
self.toastDismissTask?.cancel()
|
||||
self.toastDismissTask = nil
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Gateway",
|
||||
isPresented: self.$showGatewayActions,
|
||||
titleVisibility: .visible)
|
||||
{
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
Button("Open Settings") {
|
||||
self.selectedTab = 2
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Disconnect from the gateway?")
|
||||
}
|
||||
}
|
||||
|
||||
private var gatewayStatus: StatusPill.GatewayState {
|
||||
@@ -133,6 +155,10 @@ struct RootTabs: View {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
|
||||
7
apps/ios/Sources/RootView.swift
Normal file
7
apps/ios/Sources/RootView.swift
Normal file
@@ -0,0 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
var body: some View {
|
||||
RootCanvas()
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,20 @@ final class ScreenController {
|
||||
|
||||
func navigate(to urlString: String) {
|
||||
let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
self.urlString = ""
|
||||
self.reload()
|
||||
return
|
||||
}
|
||||
if let url = URL(string: trimmed),
|
||||
!url.isFileURL,
|
||||
let host = url.host,
|
||||
Self.isLoopbackHost(host)
|
||||
{
|
||||
// Never try to load loopback URLs from a remote gateway.
|
||||
self.showDefaultCanvas()
|
||||
return
|
||||
}
|
||||
self.urlString = (trimmed == "/" ? "" : trimmed)
|
||||
self.reload()
|
||||
}
|
||||
@@ -239,6 +253,18 @@ final class ScreenController {
|
||||
name: "scaffold",
|
||||
ext: "html",
|
||||
subdirectory: "CanvasScaffold")
|
||||
|
||||
private static func isLoopbackHost(_ host: String) -> Bool {
|
||||
let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if normalized.isEmpty { return true }
|
||||
if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
|
||||
return true
|
||||
}
|
||||
if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
|
||||
guard url.isFileURL else { return false }
|
||||
let std = url.standardizedFileURL
|
||||
|
||||
@@ -9,7 +9,9 @@ struct ScreenTab: View {
|
||||
ScreenWebView(controller: self.appModel.screen)
|
||||
.ignoresSafeArea()
|
||||
.overlay(alignment: .top) {
|
||||
if let errorText = self.appModel.screen.errorText {
|
||||
if let errorText = self.appModel.screen.errorText,
|
||||
self.appModel.gatewayServerName == nil
|
||||
{
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.padding(10)
|
||||
|
||||
64
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
64
apps/ios/Sources/Services/NodeServiceProtocols.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import UIKit
|
||||
|
||||
protocol CameraServicing: Sendable {
|
||||
func listDevices() async -> [CameraController.CameraDeviceInfo]
|
||||
func snap(params: OpenClawCameraSnapParams) async throws -> (format: String, base64: String, width: Int, height: Int)
|
||||
func clip(params: OpenClawCameraClipParams) async throws -> (format: String, base64: String, durationMs: Int, hasAudio: Bool)
|
||||
}
|
||||
|
||||
protocol ScreenRecordingServicing: Sendable {
|
||||
func record(
|
||||
screenIndex: Int?,
|
||||
durationMs: Int?,
|
||||
fps: Double?,
|
||||
includeAudio: Bool?,
|
||||
outPath: String?) async throws -> String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol LocationServicing: Sendable {
|
||||
func authorizationStatus() -> CLAuthorizationStatus
|
||||
func accuracyAuthorization() -> CLAccuracyAuthorization
|
||||
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus
|
||||
func currentLocation(
|
||||
params: OpenClawLocationGetParams,
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
}
|
||||
|
||||
protocol DeviceStatusServicing: Sendable {
|
||||
func status() async throws -> OpenClawDeviceStatusPayload
|
||||
func info() -> OpenClawDeviceInfoPayload
|
||||
}
|
||||
|
||||
protocol PhotosServicing: Sendable {
|
||||
func latest(params: OpenClawPhotosLatestParams) async throws -> OpenClawPhotosLatestPayload
|
||||
}
|
||||
|
||||
protocol ContactsServicing: Sendable {
|
||||
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload
|
||||
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload
|
||||
}
|
||||
|
||||
protocol CalendarServicing: Sendable {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload
|
||||
}
|
||||
|
||||
protocol RemindersServicing: Sendable {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload
|
||||
}
|
||||
|
||||
protocol MotionServicing: Sendable {
|
||||
func activities(params: OpenClawMotionActivityParams) async throws -> OpenClawMotionActivityPayload
|
||||
func pedometer(params: OpenClawPedometerParams) async throws -> OpenClawPedometerPayload
|
||||
}
|
||||
|
||||
extension CameraController: CameraServicing {}
|
||||
extension ScreenRecordService: ScreenRecordingServicing {}
|
||||
extension LocationService: LocationServicing {}
|
||||
58
apps/ios/Sources/Services/NotificationService.swift
Normal file
58
apps/ios/Sources/Services/NotificationService.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
enum NotificationAuthorizationStatus: Sendable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case authorized
|
||||
case provisional
|
||||
case ephemeral
|
||||
}
|
||||
|
||||
protocol NotificationCentering: Sendable {
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
}
|
||||
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
|
||||
init(center: UNUserNotificationCenter = .current()) {
|
||||
self.center = center
|
||||
}
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
let settings = await self.center.notificationSettings()
|
||||
return switch settings.authorizationStatus {
|
||||
case .authorized:
|
||||
.authorized
|
||||
case .provisional:
|
||||
.provisional
|
||||
case .ephemeral:
|
||||
.ephemeral
|
||||
case .denied:
|
||||
.denied
|
||||
case .notDetermined:
|
||||
.notDetermined
|
||||
@unknown default:
|
||||
.denied
|
||||
}
|
||||
}
|
||||
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||
try await self.center.requestAuthorization(options: options)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
self.center.add(request) { error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,14 @@ enum SessionKey {
|
||||
return trimmed.isEmpty ? "main" : trimmed
|
||||
}
|
||||
|
||||
static func makeAgentSessionKey(agentId: String, baseKey: String) -> String {
|
||||
let trimmedAgent = agentId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedBase = baseKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedAgent.isEmpty { return trimmedBase.isEmpty ? "main" : trimmedBase }
|
||||
let normalizedBase = trimmedBase.isEmpty ? "main" : trimmedBase
|
||||
return "agent:\(trimmedAgent):\(normalizedBase)"
|
||||
}
|
||||
|
||||
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return false }
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import os
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
private final class ConnectStatusStore {
|
||||
var text: String?
|
||||
}
|
||||
|
||||
extension ConnectStatusStore: @unchecked Sendable {}
|
||||
|
||||
struct SettingsTab: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake: VoiceWakeManager
|
||||
@@ -28,99 +21,140 @@ struct SettingsTab: View {
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.autoconnect") private var gatewayAutoConnect: Bool = false
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("gateway.manual.port") private var manualGatewayPort: Int = 18789
|
||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@State private var connectStatus = ConnectStatusStore()
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@AppStorage("gateway.setupCode") private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualGatewayPortText: String = ""
|
||||
@State private var gatewayExpanded: Bool = true
|
||||
@State private var selectedAgentPickerId: String = ""
|
||||
|
||||
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Node") {
|
||||
TextField("Name", text: self.$displayName)
|
||||
Text(self.instanceId)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent("IP", value: self.localIPAddress ?? "—")
|
||||
.contextMenu {
|
||||
if let ip = self.localIPAddress {
|
||||
Button {
|
||||
UIPasteboard.general.string = ip
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: self.$gatewayExpanded) {
|
||||
if !self.isGatewayConnected {
|
||||
Text(
|
||||
"1. Open Telegram and message your bot: /pair\n"
|
||||
+ "2. Copy the setup code it returns\n"
|
||||
+ "3. Paste here and tap Connect\n"
|
||||
+ "4. Back in Telegram, run /pair approve")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let warning = self.tailnetWarningText {
|
||||
Text(warning)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
TextField("Paste setup code", text: self.$setupCode)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
Task { await self.applySetupCodeAndConnect() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect with setup code")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil
|
||||
|| self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
if let status = self.setupStatusLine {
|
||||
Text(status)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent("Platform", value: self.platformString())
|
||||
LabeledContent("Version", value: self.appVersion())
|
||||
LabeledContent("Model", value: self.modelIdentifier())
|
||||
}
|
||||
|
||||
Section("Gateway") {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
if let serverName = self.appModel.gatewayServerName {
|
||||
LabeledContent("Server", value: serverName)
|
||||
if let addr = self.appModel.gatewayRemoteAddress {
|
||||
let parts = Self.parseHostPort(from: addr)
|
||||
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
||||
LabeledContent("Address") {
|
||||
Text(urlString)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = urlString
|
||||
} label: {
|
||||
Label("Copy URL", systemImage: "doc.on.doc")
|
||||
if self.isGatewayConnected {
|
||||
Picker("Bot", selection: self.$selectedAgentPickerId) {
|
||||
Text("Default").tag("")
|
||||
let defaultId = (self.appModel.gatewayDefaultAgentId ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
ForEach(self.appModel.gatewayAgents.filter { $0.id != defaultId }, id: \.id) { agent in
|
||||
let name = (agent.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Text(name.isEmpty ? agent.id : name).tag(agent.id)
|
||||
}
|
||||
}
|
||||
Text("Controls which bot Chat and Talk speak to.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let parts {
|
||||
DisclosureGroup("Advanced") {
|
||||
if self.appModel.gatewayServerName == nil {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
Toggle("Auto-connect on launch", isOn: self.$gatewayAutoConnect)
|
||||
|
||||
if let serverName = self.appModel.gatewayServerName {
|
||||
LabeledContent("Server", value: serverName)
|
||||
if let addr = self.appModel.gatewayRemoteAddress {
|
||||
let parts = Self.parseHostPort(from: addr)
|
||||
let urlString = Self.httpURLString(host: parts?.host, port: parts?.port, fallback: addr)
|
||||
LabeledContent("Address") {
|
||||
Text(urlString)
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
UIPasteboard.general.string = parts.host
|
||||
UIPasteboard.general.string = urlString
|
||||
} label: {
|
||||
Label("Copy Host", systemImage: "doc.on.doc")
|
||||
Label("Copy URL", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = "\(parts.port)"
|
||||
} label: {
|
||||
Label("Copy Port", systemImage: "doc.on.doc")
|
||||
if let parts {
|
||||
Button {
|
||||
UIPasteboard.general.string = parts.host
|
||||
} label: {
|
||||
Label("Copy Host", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button {
|
||||
UIPasteboard.general.string = "\(parts.port)"
|
||||
} label: {
|
||||
Label("Copy Port", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
} else {
|
||||
self.gatewayList(showing: .all)
|
||||
}
|
||||
|
||||
Button("Disconnect", role: .destructive) {
|
||||
self.appModel.disconnectGateway()
|
||||
}
|
||||
|
||||
self.gatewayList(showing: .availableOnly)
|
||||
} else {
|
||||
self.gatewayList(showing: .all)
|
||||
}
|
||||
|
||||
if let text = self.connectStatus.text {
|
||||
Text(text)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port", value: self.$manualGatewayPort, format: .number)
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
@@ -140,11 +174,11 @@ struct SettingsTab: View {
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || self.manualGatewayPort <= 0 || self.manualGatewayPort > 65535)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "The gateway WebSocket listens on port 18789 by default.")
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -164,58 +198,98 @@ struct SettingsTab: View {
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDebugText())
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Voice") {
|
||||
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
|
||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
Toggle("Talk Mode", isOn: self.$talkEnabled)
|
||||
.onChange(of: self.talkEnabled) { _, newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
} label: {
|
||||
LabeledContent(
|
||||
"Wake Words",
|
||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.isGatewayConnected ? Color.green : Color.secondary.opacity(0.35))
|
||||
.frame(width: 10, height: 10)
|
||||
Text("Gateway")
|
||||
Spacer()
|
||||
Text(self.gatewaySummaryText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Camera") {
|
||||
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Device") {
|
||||
DisclosureGroup("Features") {
|
||||
Toggle("Voice Wake", isOn: self.$voiceWakeEnabled)
|
||||
.onChange(of: self.voiceWakeEnabled) { _, newValue in
|
||||
self.appModel.setVoiceWakeEnabled(newValue)
|
||||
}
|
||||
Toggle("Talk Mode", isOn: self.$talkEnabled)
|
||||
.onChange(of: self.talkEnabled) { _, newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||
|
||||
Section("Location") {
|
||||
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
||||
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
||||
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
||||
NavigationLink {
|
||||
VoiceWakeWordsSettingsView()
|
||||
} label: {
|
||||
LabeledContent(
|
||||
"Wake Words",
|
||||
value: VoiceWakePreferences.displayString(for: self.voiceWake.triggerWords))
|
||||
}
|
||||
|
||||
Toggle("Allow Camera", isOn: self.$cameraEnabled)
|
||||
Text("Allows the gateway to request photos or short video clips (foreground only).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("Location Access", selection: self.$locationEnabledModeRaw) {
|
||||
Text("Off").tag(OpenClawLocationMode.off.rawValue)
|
||||
Text("While Using").tag(OpenClawLocationMode.whileUsing.rawValue)
|
||||
Text("Always").tag(OpenClawLocationMode.always.rawValue)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always requires system permission and may prompt to open Settings.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||
Text("Keeps the screen awake while OpenClaw is open.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||
.disabled(self.locationMode == .off)
|
||||
|
||||
Text("Always requires system permission and may prompt to open Settings.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Screen") {
|
||||
Toggle("Prevent Sleep", isOn: self.$preventSleep)
|
||||
Text("Keeps the screen awake while OpenClaw is open.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
DisclosureGroup("Device Info") {
|
||||
TextField("Name", text: self.$displayName)
|
||||
Text(self.instanceId)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent("IP", value: self.localIPAddress ?? "—")
|
||||
.contextMenu {
|
||||
if let ip = self.localIPAddress {
|
||||
Button {
|
||||
UIPasteboard.general.string = ip
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
}
|
||||
LabeledContent("Platform", value: self.platformString())
|
||||
LabeledContent("Version", value: self.appVersion())
|
||||
LabeledContent("Model", value: self.modelIdentifier())
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
@@ -232,11 +306,24 @@ struct SettingsTab: View {
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -255,8 +342,24 @@ struct SettingsTab: View {
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in
|
||||
self.connectStatus.text = nil
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
return
|
||||
}
|
||||
if self.manualGatewayEnabled {
|
||||
self.setupStatusText = self.appModel.gatewayStatusText
|
||||
}
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.setupStatusText = trimmed
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
@@ -278,8 +381,24 @@ struct SettingsTab: View {
|
||||
@ViewBuilder
|
||||
private func gatewayList(showing: GatewayListMode) -> some View {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("If your gateway is on another network, connect it and ensure DNS is working.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
Button {
|
||||
Task { await self.connectLastKnown() }
|
||||
} label: {
|
||||
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(self.appModel.seamColor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let connectedID = self.appModel.connectedGatewayID
|
||||
let rows = self.gatewayController.gateways.filter { gateway in
|
||||
@@ -331,6 +450,20 @@ struct SettingsTab: View {
|
||||
case availableOnly
|
||||
}
|
||||
|
||||
private var isGatewayConnected: Bool {
|
||||
let status = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if status.contains("connected") { return true }
|
||||
return self.appModel.gatewayServerName != nil && !status.contains("offline")
|
||||
}
|
||||
|
||||
private var gatewaySummaryText: String {
|
||||
if let server = self.appModel.gatewayServerName, self.isGatewayConnected {
|
||||
return server
|
||||
}
|
||||
let trimmed = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Not connected" : trimmed
|
||||
}
|
||||
|
||||
private func platformString() -> String {
|
||||
let v = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
|
||||
@@ -377,14 +510,290 @@ struct SettingsTab: View {
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func connectLastKnown() async {
|
||||
self.connectingGatewayID = "last-known"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
|
||||
private func gatewayDebugText() -> String {
|
||||
var lines: [String] = [
|
||||
"gateway: \(self.appModel.gatewayStatusText)",
|
||||
"discovery: \(self.gatewayController.discoveryStatusText)",
|
||||
]
|
||||
lines.append("server: \(self.appModel.gatewayServerName ?? "—")")
|
||||
lines.append("address: \(self.appModel.gatewayRemoteAddress ?? "—")")
|
||||
if let last = self.gatewayController.discoveryDebugLog.last?.message {
|
||||
lines.append("discovery log: \(last)")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lastKnownButtonLabel(host: String, port: Int) -> some View {
|
||||
if self.connectingGatewayID == "last-known" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "bolt.horizontal.circle.fill")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Connect last known")
|
||||
Text("\(host):\(port)")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private var manualPortBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.manualGatewayPortText },
|
||||
set: { newValue in
|
||||
let filtered = newValue.filter(\.isNumber)
|
||||
if self.manualGatewayPortText != filtered {
|
||||
self.manualGatewayPortText = filtered
|
||||
}
|
||||
if filtered.isEmpty {
|
||||
if self.manualGatewayPort != 0 {
|
||||
self.manualGatewayPort = 0
|
||||
}
|
||||
} else if let port = Int(filtered), self.manualGatewayPort != port {
|
||||
self.manualGatewayPort = port
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private var manualPortIsValid: Bool {
|
||||
if self.manualGatewayPortText.isEmpty { return true }
|
||||
return self.manualGatewayPort >= 1 && self.manualGatewayPort <= 65535
|
||||
}
|
||||
|
||||
private func syncManualPortText() {
|
||||
if self.manualGatewayPort > 0 {
|
||||
let next = String(self.manualGatewayPort)
|
||||
if self.manualGatewayPortText != next {
|
||||
self.manualGatewayPortText = next
|
||||
}
|
||||
} else if !self.manualGatewayPortText.isEmpty {
|
||||
self.manualGatewayPortText = ""
|
||||
}
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedPort = self.resolvedManualPort(host: host)
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
GatewayDiagnostics.log(
|
||||
"setup code applied host=\(host) port=\(resolvedPort ?? -1) tls=\(self.manualGatewayTLS) token=\(hasToken) password=\(hasPassword)")
|
||||
guard let port = resolvedPort else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
let ok = await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS)
|
||||
guard ok else { return }
|
||||
self.setupStatusText = "Setup code applied. Connecting…"
|
||||
await self.connectManual()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func applySetupCode() -> Bool {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
self.setupStatusText = "Paste a setup code to continue."
|
||||
return false
|
||||
}
|
||||
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return false
|
||||
}
|
||||
|
||||
if let urlString = payload.url, let url = URL(string: urlString) {
|
||||
self.applySetupURL(url)
|
||||
} else if let host = payload.host, !host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.manualGatewayHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let port = payload.port {
|
||||
self.manualGatewayPort = port
|
||||
self.manualGatewayPortText = String(port)
|
||||
} else {
|
||||
self.manualGatewayPort = 0
|
||||
self.manualGatewayPortText = ""
|
||||
}
|
||||
if let tls = payload.tls {
|
||||
self.manualGatewayTLS = tls
|
||||
}
|
||||
} else if let url = URL(string: raw), url.scheme != nil {
|
||||
self.applySetupURL(url)
|
||||
} else {
|
||||
self.setupStatusText = "Setup code missing URL or host."
|
||||
return false
|
||||
}
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.gatewayToken = trimmedToken
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||
}
|
||||
}
|
||||
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.gatewayPassword = trimmedPassword
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func applySetupURL(_ url: URL) {
|
||||
guard let host = url.host, !host.isEmpty else { return }
|
||||
self.manualGatewayHost = host
|
||||
if let port = url.port {
|
||||
self.manualGatewayPort = port
|
||||
self.manualGatewayPortText = String(port)
|
||||
} else {
|
||||
self.manualGatewayPort = 0
|
||||
self.manualGatewayPortText = ""
|
||||
}
|
||||
let scheme = (url.scheme ?? "").lowercased()
|
||||
if scheme == "wss" || scheme == "https" {
|
||||
self.manualGatewayTLS = true
|
||||
} else if scheme == "ws" || scheme == "http" {
|
||||
self.manualGatewayTLS = false
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedManualPort(host: String) -> Int? {
|
||||
if self.manualGatewayPort > 0 {
|
||||
return self.manualGatewayPort <= 65535 ? self.manualGatewayPort : nil
|
||||
}
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if self.manualGatewayTLS && trimmed.lowercased().hasSuffix(".ts.net") {
|
||||
return 443
|
||||
}
|
||||
return 18789
|
||||
}
|
||||
|
||||
private func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
|
||||
if Self.isTailnetHostOrIP(trimmed) && !Self.hasTailnetIPv4() {
|
||||
let msg = "Tailscale is off on this iPhone. Turn it on, then try again."
|
||||
self.setupStatusText = msg
|
||||
GatewayDiagnostics.log("preflight fail: tailnet missing host=\(trimmed)")
|
||||
self.gatewayLogger.warning("\(msg, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
self.setupStatusText = "Checking gateway reachability…"
|
||||
let ok = await Self.probeTCP(host: trimmed, port: port, timeoutSeconds: 3)
|
||||
if !ok {
|
||||
let msg = "Can't reach gateway at \(trimmed):\(port). Check Tailscale or LAN."
|
||||
self.setupStatusText = msg
|
||||
GatewayDiagnostics.log("preflight fail: unreachable host=\(trimmed) port=\(port)")
|
||||
self.gatewayLogger.warning("\(msg, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
GatewayDiagnostics.log("preflight ok host=\(trimmed) port=\(port) tls=\(useTLS)")
|
||||
return true
|
||||
}
|
||||
|
||||
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "gateway.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else {
|
||||
self.connectStatus.text = "Failed: host required"
|
||||
self.setupStatusText = "Failed: host required"
|
||||
return
|
||||
}
|
||||
guard self.manualGatewayPort > 0, self.manualGatewayPort <= 65535 else {
|
||||
self.connectStatus.text = "Failed: invalid port"
|
||||
guard self.manualPortIsValid else {
|
||||
self.setupStatusText = "Failed: invalid port"
|
||||
return
|
||||
}
|
||||
|
||||
@@ -392,12 +801,54 @@ struct SettingsTab: View {
|
||||
self.manualGatewayEnabled = true
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
GatewayDiagnostics.log(
|
||||
"connect manual host=\(host) port=\(self.manualGatewayPort) tls=\(self.manualGatewayTLS)")
|
||||
await self.gatewayController.connectManual(
|
||||
host: host,
|
||||
port: self.manualGatewayPort,
|
||||
useTLS: self.manualGatewayTLS)
|
||||
}
|
||||
|
||||
private var setupStatusLine: String? {
|
||||
let trimmedSetup = self.setupStatusText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let friendly = self.friendlyGatewayMessage(from: gatewayStatus) { return friendly }
|
||||
if let friendly = self.friendlyGatewayMessage(from: trimmedSetup) { return friendly }
|
||||
if !trimmedSetup.isEmpty { return trimmedSetup }
|
||||
if gatewayStatus.isEmpty || gatewayStatus == "Offline" { return nil }
|
||||
return gatewayStatus
|
||||
}
|
||||
|
||||
private var tailnetWarningText: String? {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty else { return nil }
|
||||
guard Self.isTailnetHostOrIP(host) else { return nil }
|
||||
guard !Self.hasTailnetIPv4() else { return nil }
|
||||
return "This gateway is on your tailnet. Turn on Tailscale on this iPhone, then tap Connect."
|
||||
}
|
||||
|
||||
private func friendlyGatewayMessage(from raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
if lower.contains("pairing required") {
|
||||
return "Pairing required. Go back to Telegram and run /pair approve, then tap Connect again."
|
||||
}
|
||||
if lower.contains("device nonce required") || lower.contains("device nonce mismatch") {
|
||||
return "Secure handshake failed. Make sure Tailscale is connected, then tap Connect again."
|
||||
}
|
||||
if lower.contains("device signature expired") || lower.contains("device signature invalid") {
|
||||
return "Secure handshake failed. Check that your iPhone time is correct, then tap Connect again."
|
||||
}
|
||||
if lower.contains("connect timed out") || lower.contains("timed out") {
|
||||
return "Connection timed out. Make sure Tailscale is connected, then try again."
|
||||
}
|
||||
if lower.contains("unauthorized role") {
|
||||
return "Connected, but some controls are restricted for nodes. This is expected."
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
@@ -436,6 +887,57 @@ struct SettingsTab: View {
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
private static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
if self.isTailnetIPv4(ip) { return true }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private static func isTailnetHostOrIP(_ host: String) -> Bool {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
if trimmed.hasSuffix(".ts.net") || trimmed.hasSuffix(".ts.net.") {
|
||||
return true
|
||||
}
|
||||
return self.isTailnetIPv4(trimmed)
|
||||
}
|
||||
|
||||
private static func isTailnetIPv4(_ ip: String) -> Bool {
|
||||
let parts = ip.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
guard (0...255).contains(a), (0...255).contains(b) else { return false }
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private static func parseHostPort(from address: String) -> SettingsHostPort? {
|
||||
SettingsNetworkingHelpers.parseHostPort(from: address)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ struct TalkOrbOverlay: View {
|
||||
var body: some View {
|
||||
let seam = self.appModel.seamColor
|
||||
let status = self.appModel.talkMode.statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let mic = min(max(self.appModel.talkMode.micLevel, 0), 1)
|
||||
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
@@ -28,7 +29,7 @@ struct TalkOrbOverlay: View {
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
seam.opacity(0.95),
|
||||
seam.opacity(0.75 + (0.20 * mic)),
|
||||
seam.opacity(0.40),
|
||||
Color.black.opacity(0.55),
|
||||
],
|
||||
@@ -36,6 +37,7 @@ struct TalkOrbOverlay: View {
|
||||
startRadius: 1,
|
||||
endRadius: 112))
|
||||
.frame(width: 190, height: 190)
|
||||
.scaleEffect(1.0 + (0.12 * mic))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(seam.opacity(0.35), lineWidth: 1))
|
||||
@@ -47,6 +49,13 @@ struct TalkOrbOverlay: View {
|
||||
self.appModel.talkMode.userTappedOrb()
|
||||
}
|
||||
|
||||
let agentName = self.appModel.activeAgentName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !agentName.isEmpty {
|
||||
Text("Bot: \(agentName)")
|
||||
.font(.system(.caption, design: .rounded).weight(.semibold))
|
||||
.foregroundStyle(Color.white.opacity(0.70))
|
||||
}
|
||||
|
||||
if !status.isEmpty, status != "Off" {
|
||||
Text(status)
|
||||
.font(.system(.footnote, design: .rounded).weight(.semibold))
|
||||
@@ -59,6 +68,14 @@ struct TalkOrbOverlay: View {
|
||||
.overlay(
|
||||
Capsule().stroke(seam.opacity(0.22), lineWidth: 1)))
|
||||
}
|
||||
|
||||
if self.appModel.talkMode.isListening {
|
||||
Capsule()
|
||||
.fill(seam.opacity(0.90))
|
||||
.frame(width: max(18, 180 * mic), height: 6)
|
||||
.animation(.easeOut(duration: 0.12), value: mic)
|
||||
.accessibilityLabel("Microphone level")
|
||||
}
|
||||
}
|
||||
.padding(28)
|
||||
.onAppear {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFAudio
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
import SwabbleKit
|
||||
|
||||
@@ -96,6 +97,7 @@ final class VoiceWakeManager: NSObject {
|
||||
private var lastDispatched: String?
|
||||
private var onCommand: (@Sendable (String) async -> Void)?
|
||||
private var userDefaultsObserver: NSObjectProtocol?
|
||||
private var suppressedByTalk: Bool = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -141,9 +143,28 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
func setSuppressedByTalk(_ suppressed: Bool) {
|
||||
self.suppressedByTalk = suppressed
|
||||
if suppressed {
|
||||
_ = self.suspendForExternalAudioCapture()
|
||||
if self.isEnabled {
|
||||
self.statusText = "Paused"
|
||||
}
|
||||
} else {
|
||||
if self.isEnabled {
|
||||
Task { await self.start() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() async {
|
||||
guard self.isEnabled else { return }
|
||||
if self.isListening { return }
|
||||
guard !self.suppressedByTalk else {
|
||||
self.isListening = false
|
||||
self.statusText = "Paused"
|
||||
return
|
||||
}
|
||||
|
||||
if ProcessInfo.processInfo.environment["SIMULATOR_DEVICE_NAME"] != nil ||
|
||||
ProcessInfo.processInfo.environment["SIMULATOR_UDID"] != nil
|
||||
@@ -159,14 +180,18 @@ final class VoiceWakeManager: NSObject {
|
||||
|
||||
let micOk = await Self.requestMicrophonePermission()
|
||||
guard micOk else {
|
||||
self.statusText = "Microphone permission denied"
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Microphone",
|
||||
status: AVAudioSession.sharedInstance().recordPermission)
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
|
||||
let speechOk = await Self.requestSpeechPermission()
|
||||
guard speechOk else {
|
||||
self.statusText = "Speech recognition permission denied"
|
||||
self.statusText = Self.permissionMessage(
|
||||
kind: "Speech recognition",
|
||||
status: SFSpeechRecognizer.authorizationStatus())
|
||||
self.isListening = false
|
||||
return
|
||||
}
|
||||
@@ -364,20 +389,101 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
|
||||
private nonisolated static func requestMicrophonePermission() async -> Bool {
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
AVAudioApplication.requestRecordPermission { ok in
|
||||
cont.resume(returning: ok)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch session.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
break
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { ok in
|
||||
completion(ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestSpeechPermission() async -> Bool {
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
SFSpeechRecognizer.requestAuthorization { status in
|
||||
cont.resume(returning: status == .authorized)
|
||||
let status = SFSpeechRecognizer.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
return await self.requestPermissionWithTimeout { completion in
|
||||
SFSpeechRecognizer.requestAuthorization { authStatus in
|
||||
completion(authStatus == .authorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func requestPermissionWithTimeout(
|
||||
_ operation: @escaping @Sendable (@escaping (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: 8,
|
||||
onTimeout: { NSError(domain: "VoiceWake", code: 6, userInfo: [
|
||||
NSLocalizedDescriptionKey: "permission request timed out",
|
||||
]) },
|
||||
operation: {
|
||||
await withCheckedContinuation(isolation: nil) { cont in
|
||||
Task { @MainActor in
|
||||
operation { ok in
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .undetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .granted:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
{
|
||||
switch status {
|
||||
case .denied:
|
||||
return "\(kind) permission denied"
|
||||
case .restricted:
|
||||
return "\(kind) permission restricted"
|
||||
case .notDetermined:
|
||||
return "\(kind) permission not granted"
|
||||
case .authorized:
|
||||
return "\(kind) permission denied"
|
||||
@unknown default:
|
||||
return "\(kind) permission denied"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@@ -9,6 +9,7 @@ Sources/Chat/IOSGatewayChatTransport.swift
|
||||
Sources/OpenClawApp.swift
|
||||
Sources/Location/LocationService.swift
|
||||
Sources/Model/NodeAppModel.swift
|
||||
Sources/Model/NodeAppModel+Canvas.swift
|
||||
Sources/RootCanvas.swift
|
||||
Sources/RootTabs.swift
|
||||
Sources/Screen/ScreenController.swift
|
||||
|
||||
@@ -7,8 +7,8 @@ private struct KeychainEntry: Hashable {
|
||||
let account: String
|
||||
}
|
||||
|
||||
private let gatewayService = "bot.molt.gateway"
|
||||
private let nodeService = "bot.molt.node"
|
||||
private let gatewayService = "ai.openclaw.gateway"
|
||||
private let nodeService = "ai.openclaw.node"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.6</string>
|
||||
<string>2026.2.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260202</string>
|
||||
</dict>
|
||||
|
||||
@@ -101,7 +101,8 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(presentRes.ok == true)
|
||||
#expect(appModel.screen.urlString.isEmpty)
|
||||
|
||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://localhost:18789/")
|
||||
// Loopback URLs are rejected (they are not meaningful for a remote gateway).
|
||||
let navigateParams = OpenClawCanvasNavigateParams(url: "http://example.com/")
|
||||
let navData = try JSONEncoder().encode(navigateParams)
|
||||
let navJSON = String(decoding: navData, as: UTF8.self)
|
||||
let navigate = BridgeInvokeRequest(
|
||||
@@ -110,7 +111,7 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
paramsJSON: navJSON)
|
||||
let navRes = await appModel._test_handleInvoke(navigate)
|
||||
#expect(navRes.ok == true)
|
||||
#expect(appModel.screen.urlString == "http://localhost:18789/")
|
||||
#expect(appModel.screen.urlString == "http://example.com/")
|
||||
|
||||
let evalParams = OpenClawCanvasEvalParams(javaScript: "1+1")
|
||||
let evalData = try JSONEncoder().encode(evalParams)
|
||||
|
||||
@@ -81,7 +81,7 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.6"
|
||||
CFBundleShortVersionString: "2026.2.9"
|
||||
CFBundleVersion: "20260202"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.6"
|
||||
CFBundleShortVersionString: "2026.2.9"
|
||||
CFBundleVersion: "20260202"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
// Stable identifier used for both the macOS LaunchAgent label and Nix-managed defaults suite.
|
||||
// nix-openclaw writes app defaults into this suite to survive app bundle identifier churn.
|
||||
let launchdLabel = "ai.openclaw.mac"
|
||||
let gatewayLaunchdLabel = "ai.openclaw.gateway"
|
||||
let onboardingVersionKey = "openclaw.onboardingVersion"
|
||||
|
||||
@@ -33,6 +33,7 @@ struct OpenClawApp: App {
|
||||
|
||||
init() {
|
||||
OpenClawLogging.bootstrapIfNeeded()
|
||||
|
||||
Self.applyAttachOnlyOverrideIfNeeded()
|
||||
_state = State(initialValue: AppStateStore.shared)
|
||||
}
|
||||
|
||||
@@ -6,9 +6,32 @@ extension ProcessInfo {
|
||||
return String(cString: raw) == "1"
|
||||
}
|
||||
|
||||
/// Nix deployments may write defaults into a stable suite (`ai.openclaw.mac`) even if the shipped
|
||||
/// app bundle identifier changes (and therefore `UserDefaults.standard` domain changes).
|
||||
static func resolveNixMode(
|
||||
environment: [String: String],
|
||||
standard: UserDefaults,
|
||||
stableSuite: UserDefaults?,
|
||||
isAppBundle: Bool
|
||||
) -> Bool {
|
||||
if environment["OPENCLAW_NIX_MODE"] == "1" { return true }
|
||||
if standard.bool(forKey: "openclaw.nixMode") { return true }
|
||||
|
||||
// Only consult the stable suite when running as a .app bundle.
|
||||
// This avoids local developer machines accidentally influencing unit tests.
|
||||
if isAppBundle, let stableSuite, stableSuite.bool(forKey: "openclaw.nixMode") { return true }
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
var isNixMode: Bool {
|
||||
if let raw = getenv("OPENCLAW_NIX_MODE"), String(cString: raw) == "1" { return true }
|
||||
return UserDefaults.standard.bool(forKey: "openclaw.nixMode")
|
||||
let isAppBundle = Bundle.main.bundleURL.pathExtension == "app"
|
||||
let stableSuite = UserDefaults(suiteName: launchdLabel)
|
||||
return Self.resolveNixMode(
|
||||
environment: self.environment,
|
||||
standard: .standard,
|
||||
stableSuite: stableSuite,
|
||||
isAppBundle: isAppBundle)
|
||||
}
|
||||
|
||||
var isRunningTests: Bool {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.6</string>
|
||||
<string>2026.2.9</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202602020</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@@ -1589,6 +1589,140 @@ public struct AgentSummary: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsCreateParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let workspace: String
|
||||
public let emoji: String?
|
||||
public let avatar: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
workspace: String,
|
||||
emoji: String?,
|
||||
avatar: String?
|
||||
) {
|
||||
self.name = name
|
||||
self.workspace = workspace
|
||||
self.emoji = emoji
|
||||
self.avatar = avatar
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case workspace
|
||||
case emoji
|
||||
case avatar
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsCreateResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let agentid: String
|
||||
public let name: String
|
||||
public let workspace: String
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
agentid: String,
|
||||
name: String,
|
||||
workspace: String
|
||||
) {
|
||||
self.ok = ok
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.workspace = workspace
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case workspace
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsUpdateParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String?
|
||||
public let workspace: String?
|
||||
public let model: String?
|
||||
public let avatar: String?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String?,
|
||||
workspace: String?,
|
||||
model: String?,
|
||||
avatar: String?
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.workspace = workspace
|
||||
self.model = model
|
||||
self.avatar = avatar
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case workspace
|
||||
case model
|
||||
case avatar
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsUpdateResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let agentid: String
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
agentid: String
|
||||
) {
|
||||
self.ok = ok
|
||||
self.agentid = agentid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsDeleteParams: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let deletefiles: Bool?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
deletefiles: Bool?
|
||||
) {
|
||||
self.agentid = agentid
|
||||
self.deletefiles = deletefiles
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
case deletefiles = "deleteFiles"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsDeleteResult: Codable, Sendable {
|
||||
public let ok: Bool
|
||||
public let agentid: String
|
||||
public let removedbindings: Int
|
||||
|
||||
public init(
|
||||
ok: Bool,
|
||||
agentid: String,
|
||||
removedbindings: Int
|
||||
) {
|
||||
self.ok = ok
|
||||
self.agentid = agentid
|
||||
self.removedbindings = removedbindings
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case agentid = "agentId"
|
||||
case removedbindings = "removedBindings"
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentsFileEntry: Codable, Sendable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
struct NixModeStableSuiteTests {
|
||||
@Test func resolvesFromStableSuiteForAppBundles() {
|
||||
let suite = UserDefaults(suiteName: launchdLabel)!
|
||||
let key = "openclaw.nixMode"
|
||||
let prev = suite.object(forKey: key)
|
||||
defer {
|
||||
if let prev { suite.set(prev, forKey: key) } else { suite.removeObject(forKey: key) }
|
||||
}
|
||||
|
||||
suite.set(true, forKey: key)
|
||||
|
||||
let standard = UserDefaults(suiteName: "NixModeStableSuiteTests.\(UUID().uuidString)")!
|
||||
#expect(!standard.bool(forKey: key))
|
||||
|
||||
let resolved = ProcessInfo.resolveNixMode(
|
||||
environment: [:],
|
||||
standard: standard,
|
||||
stableSuite: suite,
|
||||
isAppBundle: true)
|
||||
#expect(resolved)
|
||||
}
|
||||
|
||||
@Test func ignoresStableSuiteOutsideAppBundles() {
|
||||
let suite = UserDefaults(suiteName: launchdLabel)!
|
||||
let key = "openclaw.nixMode"
|
||||
let prev = suite.object(forKey: key)
|
||||
defer {
|
||||
if let prev { suite.set(prev, forKey: key) } else { suite.removeObject(forKey: key) }
|
||||
}
|
||||
|
||||
suite.set(true, forKey: key)
|
||||
let standard = UserDefaults(suiteName: "NixModeStableSuiteTests.\(UUID().uuidString)")!
|
||||
|
||||
let resolved = ProcessInfo.resolveNixMode(
|
||||
environment: [:],
|
||||
standard: standard,
|
||||
stableSuite: suite,
|
||||
isAppBundle: false)
|
||||
#expect(!resolved)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Darwin
|
||||
import Testing
|
||||
@testable import OpenClawDiscovery
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawCalendarCommand: String, Codable, Sendable {
|
||||
case events = "calendar.events"
|
||||
case add = "calendar.add"
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable {
|
||||
public var startISO: String?
|
||||
public var endISO: String?
|
||||
public var limit: Int?
|
||||
|
||||
public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var isAllDay: Bool?
|
||||
public var location: String?
|
||||
public var notes: String?
|
||||
public var calendarId: String?
|
||||
public var calendarTitle: String?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
isAllDay: Bool? = nil,
|
||||
location: String? = nil,
|
||||
notes: String? = nil,
|
||||
calendarId: String? = nil,
|
||||
calendarTitle: String? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.isAllDay = isAllDay
|
||||
self.location = location
|
||||
self.notes = notes
|
||||
self.calendarId = calendarId
|
||||
self.calendarTitle = calendarTitle
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarEventPayload: Codable, Sendable, Equatable {
|
||||
public var identifier: String
|
||||
public var title: String
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var isAllDay: Bool
|
||||
public var location: String?
|
||||
public var calendarTitle: String?
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
title: String,
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
isAllDay: Bool,
|
||||
location: String? = nil,
|
||||
calendarTitle: String? = nil)
|
||||
{
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.isAllDay = isAllDay
|
||||
self.location = location
|
||||
self.calendarTitle = calendarTitle
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarEventsPayload: Codable, Sendable, Equatable {
|
||||
public var events: [OpenClawCalendarEventPayload]
|
||||
|
||||
public init(events: [OpenClawCalendarEventPayload]) {
|
||||
self.events = events
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawCalendarAddPayload: Codable, Sendable, Equatable {
|
||||
public var event: OpenClawCalendarEventPayload
|
||||
|
||||
public init(event: OpenClawCalendarEventPayload) {
|
||||
self.event = event
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,10 @@ public enum OpenClawCapability: String, Codable, Sendable {
|
||||
case screen
|
||||
case voiceWake
|
||||
case location
|
||||
case device
|
||||
case photos
|
||||
case contacts
|
||||
case calendar
|
||||
case reminders
|
||||
case motion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawChatCommand: String, Codable, Sendable {
|
||||
case push = "chat.push"
|
||||
}
|
||||
|
||||
public struct OpenClawChatPushParams: Codable, Sendable, Equatable {
|
||||
public var text: String
|
||||
public var speak: Bool?
|
||||
|
||||
public init(text: String, speak: Bool? = nil) {
|
||||
self.text = text
|
||||
self.speak = speak
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawChatPushPayload: Codable, Sendable, Equatable {
|
||||
public var messageId: String?
|
||||
|
||||
public init(messageId: String? = nil) {
|
||||
self.messageId = messageId
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawContactsCommand: String, Codable, Sendable {
|
||||
case search = "contacts.search"
|
||||
case add = "contacts.add"
|
||||
}
|
||||
|
||||
public struct OpenClawContactsSearchParams: Codable, Sendable, Equatable {
|
||||
public var query: String?
|
||||
public var limit: Int?
|
||||
|
||||
public init(query: String? = nil, limit: Int? = nil) {
|
||||
self.query = query
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactsAddParams: Codable, Sendable, Equatable {
|
||||
public var givenName: String?
|
||||
public var familyName: String?
|
||||
public var organizationName: String?
|
||||
public var displayName: String?
|
||||
public var phoneNumbers: [String]?
|
||||
public var emails: [String]?
|
||||
|
||||
public init(
|
||||
givenName: String? = nil,
|
||||
familyName: String? = nil,
|
||||
organizationName: String? = nil,
|
||||
displayName: String? = nil,
|
||||
phoneNumbers: [String]? = nil,
|
||||
emails: [String]? = nil)
|
||||
{
|
||||
self.givenName = givenName
|
||||
self.familyName = familyName
|
||||
self.organizationName = organizationName
|
||||
self.displayName = displayName
|
||||
self.phoneNumbers = phoneNumbers
|
||||
self.emails = emails
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactPayload: Codable, Sendable, Equatable {
|
||||
public var identifier: String
|
||||
public var displayName: String
|
||||
public var givenName: String
|
||||
public var familyName: String
|
||||
public var organizationName: String
|
||||
public var phoneNumbers: [String]
|
||||
public var emails: [String]
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
displayName: String,
|
||||
givenName: String,
|
||||
familyName: String,
|
||||
organizationName: String,
|
||||
phoneNumbers: [String],
|
||||
emails: [String])
|
||||
{
|
||||
self.identifier = identifier
|
||||
self.displayName = displayName
|
||||
self.givenName = givenName
|
||||
self.familyName = familyName
|
||||
self.organizationName = organizationName
|
||||
self.phoneNumbers = phoneNumbers
|
||||
self.emails = emails
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactsSearchPayload: Codable, Sendable, Equatable {
|
||||
public var contacts: [OpenClawContactPayload]
|
||||
|
||||
public init(contacts: [OpenClawContactPayload]) {
|
||||
self.contacts = contacts
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawContactsAddPayload: Codable, Sendable, Equatable {
|
||||
public var contact: OpenClawContactPayload
|
||||
|
||||
public init(contact: OpenClawContactPayload) {
|
||||
self.contact = contact
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user