Compare commits
112 Commits
dev/ci
...
fix/twitch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11910d49e0 | ||
|
|
9b8481c834 | ||
|
|
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 |
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
|
||||
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.
|
||||
340
.github/workflows/ci.yml
vendored
340
.github/workflows/ci.yml
vendored
@@ -10,7 +10,118 @@ concurrency:
|
||||
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]
|
||||
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
|
||||
@@ -37,20 +148,76 @@ jobs:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- 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 (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: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
install-check:
|
||||
needs: [docs-scope, changed-scope]
|
||||
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: Checkout submodules (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
|
||||
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 "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
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 + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
node -v
|
||||
@@ -71,6 +238,8 @@ jobs:
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope]
|
||||
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
|
||||
@@ -79,21 +248,15 @@ 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
|
||||
@@ -119,19 +282,11 @@ jobs:
|
||||
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
|
||||
@@ -161,6 +316,76 @@ jobs:
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
# Lint and format always run, even on docs-only changes.
|
||||
checks-lint:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: pnpm lint
|
||||
- task: format
|
||||
command: pnpm format
|
||||
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 + 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
|
||||
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: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
secrets:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -187,6 +412,8 @@ jobs:
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope, build-artifacts]
|
||||
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
|
||||
@@ -201,8 +428,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
|
||||
@@ -247,25 +474,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
|
||||
@@ -300,7 +533,8 @@ jobs:
|
||||
# running 4 separate jobs per PR (as before) starved the queue. One job
|
||||
# per PR allows 5 PRs to run macOS checks simultaneously.
|
||||
macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -328,19 +562,11 @@ jobs:
|
||||
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: Runtime versions
|
||||
run: |
|
||||
@@ -585,6 +811,8 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope]
|
||||
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
|
||||
|
||||
16
.github/workflows/install-smoke.yml
vendored
16
.github/workflows/install-smoke.yml
vendored
@@ -11,7 +11,23 @@ concurrency:
|
||||
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
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -3,11 +3,11 @@ node_modules
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
*.bun-build
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
coverage
|
||||
.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
@@ -16,6 +16,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 +57,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 +64,6 @@ apps/ios/*.dSYM.zip
|
||||
|
||||
# provisioning profiles (local)
|
||||
apps/ios/*.mobileprovision
|
||||
.env
|
||||
|
||||
# Local untracked files
|
||||
.local/
|
||||
@@ -72,6 +75,7 @@ USER.md
|
||||
.serena/
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
memory/
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
local/
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,6 +2,51 @@
|
||||
|
||||
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
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602030
|
||||
versionName = "2026.2.6"
|
||||
versionName = "2026.2.9"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
134
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
Normal file
134
apps/shared/OpenClawKit/Sources/OpenClawKit/DeviceCommands.swift
Normal file
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawDeviceCommand: String, Codable, Sendable {
|
||||
case status = "device.status"
|
||||
case info = "device.info"
|
||||
}
|
||||
|
||||
public enum OpenClawBatteryState: String, Codable, Sendable {
|
||||
case unknown
|
||||
case unplugged
|
||||
case charging
|
||||
case full
|
||||
}
|
||||
|
||||
public enum OpenClawThermalState: String, Codable, Sendable {
|
||||
case nominal
|
||||
case fair
|
||||
case serious
|
||||
case critical
|
||||
}
|
||||
|
||||
public enum OpenClawNetworkPathStatus: String, Codable, Sendable {
|
||||
case satisfied
|
||||
case unsatisfied
|
||||
case requiresConnection
|
||||
}
|
||||
|
||||
public enum OpenClawNetworkInterfaceType: String, Codable, Sendable {
|
||||
case wifi
|
||||
case cellular
|
||||
case wired
|
||||
case other
|
||||
}
|
||||
|
||||
public struct OpenClawBatteryStatusPayload: Codable, Sendable, Equatable {
|
||||
public var level: Double?
|
||||
public var state: OpenClawBatteryState
|
||||
public var lowPowerModeEnabled: Bool
|
||||
|
||||
public init(level: Double?, state: OpenClawBatteryState, lowPowerModeEnabled: Bool) {
|
||||
self.level = level
|
||||
self.state = state
|
||||
self.lowPowerModeEnabled = lowPowerModeEnabled
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawThermalStatusPayload: Codable, Sendable, Equatable {
|
||||
public var state: OpenClawThermalState
|
||||
|
||||
public init(state: OpenClawThermalState) {
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawStorageStatusPayload: Codable, Sendable, Equatable {
|
||||
public var totalBytes: Int64
|
||||
public var freeBytes: Int64
|
||||
public var usedBytes: Int64
|
||||
|
||||
public init(totalBytes: Int64, freeBytes: Int64, usedBytes: Int64) {
|
||||
self.totalBytes = totalBytes
|
||||
self.freeBytes = freeBytes
|
||||
self.usedBytes = usedBytes
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawNetworkStatusPayload: Codable, Sendable, Equatable {
|
||||
public var status: OpenClawNetworkPathStatus
|
||||
public var isExpensive: Bool
|
||||
public var isConstrained: Bool
|
||||
public var interfaces: [OpenClawNetworkInterfaceType]
|
||||
|
||||
public init(
|
||||
status: OpenClawNetworkPathStatus,
|
||||
isExpensive: Bool,
|
||||
isConstrained: Bool,
|
||||
interfaces: [OpenClawNetworkInterfaceType])
|
||||
{
|
||||
self.status = status
|
||||
self.isExpensive = isExpensive
|
||||
self.isConstrained = isConstrained
|
||||
self.interfaces = interfaces
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawDeviceStatusPayload: Codable, Sendable, Equatable {
|
||||
public var battery: OpenClawBatteryStatusPayload
|
||||
public var thermal: OpenClawThermalStatusPayload
|
||||
public var storage: OpenClawStorageStatusPayload
|
||||
public var network: OpenClawNetworkStatusPayload
|
||||
public var uptimeSeconds: Double
|
||||
|
||||
public init(
|
||||
battery: OpenClawBatteryStatusPayload,
|
||||
thermal: OpenClawThermalStatusPayload,
|
||||
storage: OpenClawStorageStatusPayload,
|
||||
network: OpenClawNetworkStatusPayload,
|
||||
uptimeSeconds: Double)
|
||||
{
|
||||
self.battery = battery
|
||||
self.thermal = thermal
|
||||
self.storage = storage
|
||||
self.network = network
|
||||
self.uptimeSeconds = uptimeSeconds
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawDeviceInfoPayload: Codable, Sendable, Equatable {
|
||||
public var deviceName: String
|
||||
public var modelIdentifier: String
|
||||
public var systemName: String
|
||||
public var systemVersion: String
|
||||
public var appVersion: String
|
||||
public var appBuild: String
|
||||
public var locale: String
|
||||
|
||||
public init(
|
||||
deviceName: String,
|
||||
modelIdentifier: String,
|
||||
systemName: String,
|
||||
systemVersion: String,
|
||||
appVersion: String,
|
||||
appBuild: String,
|
||||
locale: String)
|
||||
{
|
||||
self.deviceName = deviceName
|
||||
self.modelIdentifier = modelIdentifier
|
||||
self.systemName = systemName
|
||||
self.systemVersion = systemVersion
|
||||
self.appVersion = appVersion
|
||||
self.appBuild = appBuild
|
||||
self.locale = locale
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ public struct GatewayConnectOptions: Sendable {
|
||||
public var clientId: String
|
||||
public var clientMode: String
|
||||
public var clientDisplayName: String?
|
||||
// When false, the connection omits the signed device identity payload.
|
||||
// This is useful for secondary "operator" connections where the shared gateway token
|
||||
// should authorize without triggering device pairing flows.
|
||||
public var includeDeviceIdentity: Bool
|
||||
|
||||
public init(
|
||||
role: String,
|
||||
@@ -81,7 +85,8 @@ public struct GatewayConnectOptions: Sendable {
|
||||
permissions: [String: Bool],
|
||||
clientId: String,
|
||||
clientMode: String,
|
||||
clientDisplayName: String?)
|
||||
clientDisplayName: String?,
|
||||
includeDeviceIdentity: Bool = true)
|
||||
{
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
@@ -91,6 +96,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||
self.clientId = clientId
|
||||
self.clientMode = clientMode
|
||||
self.clientDisplayName = clientDisplayName
|
||||
self.includeDeviceIdentity = includeDeviceIdentity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +134,7 @@ public actor GatewayChannelActor {
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
private let connectChallengeTimeoutSeconds: Double = 0.75
|
||||
private let connectChallengeTimeoutSeconds: Double = 3.0
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
@@ -307,9 +313,15 @@ public actor GatewayChannelActor {
|
||||
if !options.permissions.isEmpty {
|
||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||
let authToken = storedToken ?? self.token
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity
|
||||
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
|
||||
let storedToken =
|
||||
(includeDeviceIdentity && identity != nil)
|
||||
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
|
||||
: nil
|
||||
// If we're not sending a device identity, a device token can't be validated server-side.
|
||||
// In that mode we always use the shared gateway token/password.
|
||||
let authToken = includeDeviceIdentity ? (storedToken ?? self.token) : self.token
|
||||
let authSource: GatewayAuthSource
|
||||
if storedToken != nil {
|
||||
authSource = .deviceToken
|
||||
@@ -322,7 +334,7 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
self.lastAuthSource = authSource
|
||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
||||
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||
let canFallbackToShared = includeDeviceIdentity && storedToken != nil && self.token != nil
|
||||
if let authToken {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||
} else if let password = self.password {
|
||||
@@ -333,7 +345,7 @@ public actor GatewayChannelActor {
|
||||
let scopesValue = scopes.joined(separator: ",")
|
||||
var payloadParts = [
|
||||
connectNonce == nil ? "v1" : "v2",
|
||||
identity.deviceId,
|
||||
identity?.deviceId ?? "",
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
@@ -345,18 +357,20 @@ public actor GatewayChannelActor {
|
||||
payloadParts.append(connectNonce)
|
||||
}
|
||||
let payload = payloadParts.joined(separator: "|")
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
if includeDeviceIdentity, let identity {
|
||||
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
|
||||
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
|
||||
var device: [String: ProtoAnyCodable] = [
|
||||
"id": ProtoAnyCodable(identity.deviceId),
|
||||
"publicKey": ProtoAnyCodable(publicKey),
|
||||
"signature": ProtoAnyCodable(signature),
|
||||
"signedAt": ProtoAnyCodable(signedAtMs),
|
||||
]
|
||||
if let connectNonce {
|
||||
device["nonce"] = ProtoAnyCodable(connectNonce)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
params["device"] = ProtoAnyCodable(device)
|
||||
}
|
||||
|
||||
let frame = RequestFrame(
|
||||
@@ -371,7 +385,9 @@ public actor GatewayChannelActor {
|
||||
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||
} catch {
|
||||
if canFallbackToShared {
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
if let identity {
|
||||
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -379,7 +395,7 @@ public actor GatewayChannelActor {
|
||||
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity,
|
||||
identity: DeviceIdentity?,
|
||||
role: String
|
||||
) async throws {
|
||||
if res.ok == false {
|
||||
@@ -404,11 +420,13 @@ public actor GatewayChannelActor {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
if let identity {
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
@@ -498,7 +516,10 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
if error is ConnectChallengeError { return nil }
|
||||
if error is ConnectChallengeError {
|
||||
self.logger.warning("gateway connect challenge timed out")
|
||||
return nil
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public actor GatewayNodeSession {
|
||||
private var activeURL: URL?
|
||||
private var activeToken: String?
|
||||
private var activePassword: String?
|
||||
private var activeConnectOptionsKey: String?
|
||||
private var connectOptions: GatewayConnectOptions?
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
@@ -103,6 +104,42 @@ public actor GatewayNodeSession {
|
||||
|
||||
public init() {}
|
||||
|
||||
private func connectOptionsKey(_ options: GatewayConnectOptions) -> String {
|
||||
func sorted(_ values: [String]) -> String {
|
||||
values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
}
|
||||
let role = options.role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let scopes = sorted(options.scopes)
|
||||
let caps = sorted(options.caps)
|
||||
let commands = sorted(options.commands)
|
||||
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
|
||||
let permissions = options.permissions
|
||||
.map { key, value in
|
||||
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return "\(trimmed)=\(value ? "1" : "0")"
|
||||
}
|
||||
.sorted()
|
||||
.joined(separator: ",")
|
||||
|
||||
return [
|
||||
role,
|
||||
scopes,
|
||||
caps,
|
||||
commands,
|
||||
clientId,
|
||||
clientMode,
|
||||
clientDisplayName,
|
||||
includeDeviceIdentity,
|
||||
permissions,
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
public func connect(
|
||||
url: URL,
|
||||
token: String?,
|
||||
@@ -113,9 +150,11 @@ public actor GatewayNodeSession {
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async throws {
|
||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||
let shouldReconnect = self.activeURL != url ||
|
||||
self.activeToken != token ||
|
||||
self.activePassword != password ||
|
||||
self.activeConnectOptionsKey != nextOptionsKey ||
|
||||
self.channel == nil
|
||||
|
||||
self.connectOptions = connectOptions
|
||||
@@ -138,12 +177,13 @@ public actor GatewayNodeSession {
|
||||
},
|
||||
connectOptions: connectOptions,
|
||||
disconnectHandler: { [weak self] reason in
|
||||
await self?.onDisconnected?(reason)
|
||||
await self?.handleChannelDisconnected(reason)
|
||||
})
|
||||
self.channel = channel
|
||||
self.activeURL = url
|
||||
self.activeToken = token
|
||||
self.activePassword = password
|
||||
self.activeConnectOptionsKey = nextOptionsKey
|
||||
}
|
||||
|
||||
guard let channel = self.channel else {
|
||||
@@ -157,7 +197,6 @@ public actor GatewayNodeSession {
|
||||
_ = await self.waitForSnapshot(timeoutMs: 500)
|
||||
await self.notifyConnectedIfNeeded()
|
||||
} catch {
|
||||
await onDisconnected(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -168,6 +207,7 @@ public actor GatewayNodeSession {
|
||||
self.activeURL = nil
|
||||
self.activeToken = nil
|
||||
self.activePassword = nil
|
||||
self.activeConnectOptionsKey = nil
|
||||
self.resetConnectionState()
|
||||
}
|
||||
|
||||
@@ -249,6 +289,13 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
}
|
||||
|
||||
private func handleChannelDisconnected(_ reason: String) async {
|
||||
// The underlying channel can auto-reconnect; resetting state here ensures we surface a fresh
|
||||
// onConnected callback once a new snapshot arrives after reconnect.
|
||||
self.resetConnectionState()
|
||||
await self.onDisconnected?(reason)
|
||||
}
|
||||
|
||||
private func markSnapshotReceived() {
|
||||
self.snapshotReceived = true
|
||||
if !self.snapshotWaiters.isEmpty {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawMotionCommand: String, Codable, Sendable {
|
||||
case activity = "motion.activity"
|
||||
case pedometer = "motion.pedometer"
|
||||
}
|
||||
|
||||
public struct OpenClawMotionActivityParams: 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 OpenClawMotionActivityEntry: Codable, Sendable, Equatable {
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var confidence: String
|
||||
public var isWalking: Bool
|
||||
public var isRunning: Bool
|
||||
public var isCycling: Bool
|
||||
public var isAutomotive: Bool
|
||||
public var isStationary: Bool
|
||||
public var isUnknown: Bool
|
||||
|
||||
public init(
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
confidence: String,
|
||||
isWalking: Bool,
|
||||
isRunning: Bool,
|
||||
isCycling: Bool,
|
||||
isAutomotive: Bool,
|
||||
isStationary: Bool,
|
||||
isUnknown: Bool)
|
||||
{
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.confidence = confidence
|
||||
self.isWalking = isWalking
|
||||
self.isRunning = isRunning
|
||||
self.isCycling = isCycling
|
||||
self.isAutomotive = isAutomotive
|
||||
self.isStationary = isStationary
|
||||
self.isUnknown = isUnknown
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawMotionActivityPayload: Codable, Sendable, Equatable {
|
||||
public var activities: [OpenClawMotionActivityEntry]
|
||||
|
||||
public init(activities: [OpenClawMotionActivityEntry]) {
|
||||
self.activities = activities
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPedometerParams: Codable, Sendable, Equatable {
|
||||
public var startISO: String?
|
||||
public var endISO: String?
|
||||
|
||||
public init(startISO: String? = nil, endISO: String? = nil) {
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPedometerPayload: Codable, Sendable, Equatable {
|
||||
public var startISO: String
|
||||
public var endISO: String
|
||||
public var steps: Int?
|
||||
public var distanceMeters: Double?
|
||||
public var floorsAscended: Int?
|
||||
public var floorsDescended: Int?
|
||||
|
||||
public init(
|
||||
startISO: String,
|
||||
endISO: String,
|
||||
steps: Int?,
|
||||
distanceMeters: Double?,
|
||||
floorsAscended: Int?,
|
||||
floorsDescended: Int?)
|
||||
{
|
||||
self.startISO = startISO
|
||||
self.endISO = endISO
|
||||
self.steps = steps
|
||||
self.distanceMeters = distanceMeters
|
||||
self.floorsAscended = floorsAscended
|
||||
self.floorsDescended = floorsDescended
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawPhotosCommand: String, Codable, Sendable {
|
||||
case latest = "photos.latest"
|
||||
}
|
||||
|
||||
public struct OpenClawPhotosLatestParams: Codable, Sendable, Equatable {
|
||||
public var limit: Int?
|
||||
public var maxWidth: Int?
|
||||
public var quality: Double?
|
||||
|
||||
public init(limit: Int? = nil, maxWidth: Int? = nil, quality: Double? = nil) {
|
||||
self.limit = limit
|
||||
self.maxWidth = maxWidth
|
||||
self.quality = quality
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPhotoPayload: Codable, Sendable, Equatable {
|
||||
public var format: String
|
||||
public var base64: String
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var createdAt: String?
|
||||
|
||||
public init(format: String, base64: String, width: Int, height: Int, createdAt: String? = nil) {
|
||||
self.format = format
|
||||
self.base64 = base64
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawPhotosLatestPayload: Codable, Sendable, Equatable {
|
||||
public var photos: [OpenClawPhotoPayload]
|
||||
|
||||
public init(photos: [OpenClawPhotoPayload]) {
|
||||
self.photos = photos
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawRemindersCommand: String, Codable, Sendable {
|
||||
case list = "reminders.list"
|
||||
case add = "reminders.add"
|
||||
}
|
||||
|
||||
public enum OpenClawReminderStatusFilter: String, Codable, Sendable {
|
||||
case incomplete
|
||||
case completed
|
||||
case all
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersListParams: Codable, Sendable, Equatable {
|
||||
public var status: OpenClawReminderStatusFilter?
|
||||
public var limit: Int?
|
||||
|
||||
public init(status: OpenClawReminderStatusFilter? = nil, limit: Int? = nil) {
|
||||
self.status = status
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersAddParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var dueISO: String?
|
||||
public var notes: String?
|
||||
public var listId: String?
|
||||
public var listName: String?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
dueISO: String? = nil,
|
||||
notes: String? = nil,
|
||||
listId: String? = nil,
|
||||
listName: String? = nil)
|
||||
{
|
||||
self.title = title
|
||||
self.dueISO = dueISO
|
||||
self.notes = notes
|
||||
self.listId = listId
|
||||
self.listName = listName
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawReminderPayload: Codable, Sendable, Equatable {
|
||||
public var identifier: String
|
||||
public var title: String
|
||||
public var dueISO: String?
|
||||
public var completed: Bool
|
||||
public var listName: String?
|
||||
|
||||
public init(
|
||||
identifier: String,
|
||||
title: String,
|
||||
dueISO: String? = nil,
|
||||
completed: Bool,
|
||||
listName: String? = nil)
|
||||
{
|
||||
self.identifier = identifier
|
||||
self.title = title
|
||||
self.dueISO = dueISO
|
||||
self.completed = completed
|
||||
self.listName = listName
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersListPayload: Codable, Sendable, Equatable {
|
||||
public var reminders: [OpenClawReminderPayload]
|
||||
|
||||
public init(reminders: [OpenClawReminderPayload]) {
|
||||
self.reminders = reminders
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawRemindersAddPayload: Codable, Sendable, Equatable {
|
||||
public var reminder: OpenClawReminderPayload
|
||||
|
||||
public init(reminder: OpenClawReminderPayload) {
|
||||
self.reminder = reminder
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
public enum OpenClawTalkCommand: String, Codable, Sendable {
|
||||
case pttStart = "talk.ptt.start"
|
||||
case pttStop = "talk.ptt.stop"
|
||||
case pttCancel = "talk.ptt.cancel"
|
||||
case pttOnce = "talk.ptt.once"
|
||||
}
|
||||
|
||||
public struct OpenClawTalkPTTStartPayload: Codable, Sendable, Equatable {
|
||||
public var captureId: String
|
||||
|
||||
public init(captureId: String) {
|
||||
self.captureId = captureId
|
||||
}
|
||||
}
|
||||
|
||||
public struct OpenClawTalkPTTStopPayload: Codable, Sendable, Equatable {
|
||||
public var captureId: String
|
||||
public var transcript: String?
|
||||
public var status: String
|
||||
|
||||
public init(captureId: String, transcript: String?, status: String) {
|
||||
self.captureId = captureId
|
||||
self.transcript = transcript
|
||||
self.status = status
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
14
docs/.i18n/glossary.ja-JP.json
Normal file
14
docs/.i18n/glossary.ja-JP.json
Normal file
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" },
|
||||
{ "source": "Gateway", "target": "Gateway" },
|
||||
{ "source": "Pi", "target": "Pi" },
|
||||
{ "source": "Skills", "target": "Skills" },
|
||||
{ "source": "local loopback", "target": "local loopback" },
|
||||
{ "source": "Tailscale", "target": "Tailscale" },
|
||||
{ "source": "Getting Started", "target": "はじめに" },
|
||||
{ "source": "Getting started", "target": "はじめに" },
|
||||
{ "source": "Quick start", "target": "クイックスタート" },
|
||||
{ "source": "Quick Start", "target": "クイックスタート" },
|
||||
{ "source": "Onboarding", "target": "オンボーディング" },
|
||||
{ "source": "wizard", "target": "ウィザード" }
|
||||
]
|
||||
0
docs/.i18n/ja-JP.tm.jsonl
Normal file
0
docs/.i18n/ja-JP.tm.jsonl
Normal file
@@ -464,6 +464,13 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
|
||||
- Check the Gateway is running continuously (cron runs inside the Gateway process).
|
||||
- For `cron` schedules: confirm timezone (`--tz`) vs the host timezone.
|
||||
|
||||
### A recurring job keeps delaying after failures
|
||||
|
||||
- OpenClaw applies exponential retry backoff for recurring jobs after consecutive errors:
|
||||
30s, 1m, 5m, 15m, then 60m between retries.
|
||||
- Backoff resets automatically after the next successful run.
|
||||
- One-shot (`at`) jobs disable after a terminal run (`ok`, `error`, or `skipped`) and do not retry.
|
||||
|
||||
### Telegram delivers to the wrong place
|
||||
|
||||
- For forum topics, use `-100…:topic:<id>` so it’s explicit and unambiguous.
|
||||
|
||||
@@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds:
|
||||
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
|
||||
- **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands.
|
||||
|
||||
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
|
||||
Hooks can also be bundled inside plugins; see [Plugins](/tools/plugin#plugin-hooks).
|
||||
|
||||
Common uses:
|
||||
|
||||
@@ -18,7 +18,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
|
||||
- OpenClaw talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||
- Pairing/allowlist works the same way as other channels (`/channels/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||
- Reactions are surfaced as system events just like Slack/Telegram so agents can "mention" them before replying.
|
||||
- Advanced features: edit, unsend, reply threading, message effects, group management.
|
||||
|
||||
@@ -149,7 +149,7 @@ DMs:
|
||||
- Approve via:
|
||||
- `openclaw pairing list bluebubbles`
|
||||
- `openclaw pairing approve bluebubbles <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- Pairing is the default token exchange. Details: [Pairing](/channels/pairing)
|
||||
|
||||
Groups:
|
||||
|
||||
@@ -337,4 +337,4 @@ Prefer `chat_guid` for stable routing:
|
||||
- OpenClaw auto-hides known-broken actions based on the BlueBubbles server's macOS version. If edit still appears on macOS 26 (Tahoe), disable it manually with `channels.bluebubbles.actions.edit=false`.
|
||||
- For status/health info: `openclaw status --all` or `openclaw status --deep`.
|
||||
|
||||
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/plugin) guide.
|
||||
For general channel workflow reference, see [Channels](/channels) and the [Plugins](/tools/plugin) guide.
|
||||
|
||||
@@ -437,6 +437,6 @@ Planned features:
|
||||
|
||||
## See Also
|
||||
|
||||
- [Multi-Agent Configuration](/multi-agent-sandbox-tools)
|
||||
- [Routing Configuration](/concepts/channel-routing)
|
||||
- [Multi-Agent Configuration](/tools/multi-agent-sandbox-tools)
|
||||
- [Routing Configuration](/channels/channel-routing)
|
||||
- [Session Management](/concepts/sessions)
|
||||
@@ -68,7 +68,7 @@ Config:
|
||||
}
|
||||
```
|
||||
|
||||
See: [Broadcast Groups](/broadcast-groups).
|
||||
See: [Broadcast Groups](/channels/broadcast-groups).
|
||||
|
||||
## Config overview
|
||||
|
||||
@@ -490,14 +490,14 @@ Use `bindings` to route Feishu DMs or groups to different agents.
|
||||
agentId: "main",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
peer: { kind: "dm", id: "ou_xxx" },
|
||||
peer: { kind: "direct", id: "ou_xxx" },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "clawd-fan",
|
||||
match: {
|
||||
channel: "feishu",
|
||||
peer: { kind: "dm", id: "ou_yyy" },
|
||||
peer: { kind: "direct", id: "ou_yyy" },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -514,7 +514,7 @@ Use `bindings` to route Feishu DMs or groups to different agents.
|
||||
Routing fields:
|
||||
|
||||
- `match.channel`: `"feishu"`
|
||||
- `match.peer.kind`: `"dm"` or `"group"`
|
||||
- `match.peer.kind`: `"direct"` or `"group"`
|
||||
- `match.peer.id`: user Open ID (`ou_xxx`) or group ID (`oc_xxx`)
|
||||
|
||||
See [Get group/user IDs](#get-groupuser-ids) for lookup tips.
|
||||
|
||||
@@ -371,4 +371,4 @@ The agent system prompt includes a group intro on the first turn of a new group
|
||||
|
||||
## WhatsApp specifics
|
||||
|
||||
See [Group messages](/concepts/group-messages) for WhatsApp-only behavior (history injection, mention handling details).
|
||||
See [Group messages](/channels/group-messages) for WhatsApp-only behavior (history injection, mention handling details).
|
||||
@@ -172,16 +172,35 @@ If the Gateway runs on a Linux host/VM but iMessage must run on a Mac, Tailscale
|
||||
|
||||
Architecture:
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐ SSH (imsg rpc) ┌──────────────────────────┐
|
||||
│ Gateway host (Linux/VM) │──────────────────────────────────▶│ Mac with Messages + imsg │
|
||||
│ - openclaw gateway │ SCP (attachments) │ - Messages signed in │
|
||||
│ - channels.imessage.cliPath │◀──────────────────────────────────│ - Remote Login enabled │
|
||||
└──────────────────────────────┘ └──────────────────────────┘
|
||||
▲
|
||||
│ Tailscale tailnet (hostname or 100.x.y.z)
|
||||
▼
|
||||
user@gateway-host
|
||||
```mermaid
|
||||
%%{init: {
|
||||
'theme': 'base',
|
||||
'themeVariables': {
|
||||
'primaryColor': '#ffffff',
|
||||
'primaryTextColor': '#000000',
|
||||
'primaryBorderColor': '#000000',
|
||||
'lineColor': '#000000',
|
||||
'secondaryColor': '#f9f9fb',
|
||||
'tertiaryColor': '#ffffff',
|
||||
'clusterBkg': '#f9f9fb',
|
||||
'clusterBorder': '#000000',
|
||||
'nodeBorder': '#000000',
|
||||
'mainBkg': '#ffffff',
|
||||
'edgeLabelBackground': '#ffffff'
|
||||
}
|
||||
}}%%
|
||||
flowchart TB
|
||||
subgraph T[" "]
|
||||
subgraph Tailscale[" "]
|
||||
direction LR
|
||||
Gateway["<b>Gateway host (Linux/VM)<br></b><br>openclaw gateway<br>channels.imessage.cliPath"]
|
||||
Mac["<b>Mac with Messages + imsg<br></b><br>Messages signed in<br>Remote Login enabled"]
|
||||
end
|
||||
Gateway -- SSH (imsg rpc) --> Mac
|
||||
Mac -- SCP (attachments) --> Gateway
|
||||
direction BT
|
||||
User["user@gateway-host"] -- "Tailscale tailnet (hostname or 100.x.y.z)" --> Gateway
|
||||
end
|
||||
```
|
||||
|
||||
Concrete config example (Tailscale hostname):
|
||||
@@ -224,7 +243,7 @@ DMs:
|
||||
- Approve via:
|
||||
- `openclaw pairing list imessage`
|
||||
- `openclaw pairing approve imessage <CODE>`
|
||||
- Pairing is the default token exchange for iMessage DMs. Details: [Pairing](/start/pairing)
|
||||
- Pairing is the default token exchange for iMessage DMs. Details: [Pairing](/channels/pairing)
|
||||
|
||||
Groups:
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- Channels can run simultaneously; configure multiple and OpenClaw will route per chat.
|
||||
- Fastest setup is usually **Telegram** (simple bot token). WhatsApp requires QR pairing and
|
||||
stores more state on disk.
|
||||
- Group behavior varies by channel; see [Groups](/concepts/groups).
|
||||
- Group behavior varies by channel; see [Groups](/channels/groups).
|
||||
- DM pairing and allowlists are enforced for safety; see [Security](/gateway/security).
|
||||
- Telegram internals: [grammY notes](/channels/grammy).
|
||||
- Troubleshooting: [Channel troubleshooting](/channels/troubleshooting).
|
||||
|
||||
@@ -34,7 +34,7 @@ openclaw plugins install ./extensions/matrix
|
||||
If you choose Matrix during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ openclaw plugins install ./extensions/mattermost
|
||||
If you choose Mattermost during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Quick setup
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ openclaw plugins install ./extensions/msteams
|
||||
If you choose Teams during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ openclaw plugins install ./extensions/nextcloud-talk
|
||||
If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected,
|
||||
OpenClaw will offer the local install path automatically.
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
|
||||
@@ -52,6 +52,23 @@ Treat these as sensitive (they gate access to your assistant).
|
||||
Nodes connect to the Gateway as **devices** with `role: node`. The Gateway
|
||||
creates a device pairing request that must be approved.
|
||||
|
||||
### Pair via Telegram (recommended for iOS)
|
||||
|
||||
If you use the `device-pair` plugin, you can do first-time device pairing entirely from Telegram:
|
||||
|
||||
1. In Telegram, message your bot: `/pair`
|
||||
2. The bot replies with two messages: an instruction message and a separate **setup code** message (easy to copy/paste in Telegram).
|
||||
3. On your phone, open the OpenClaw iOS app → Settings → Gateway.
|
||||
4. Paste the setup code and connect.
|
||||
5. Back in Telegram: `/pair approve`
|
||||
|
||||
The setup code is a base64-encoded JSON payload that contains:
|
||||
|
||||
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
|
||||
- `token`: a short-lived pairing token
|
||||
|
||||
Treat the setup code like a password while it is valid.
|
||||
|
||||
### Approve a node device
|
||||
|
||||
```bash
|
||||
@@ -109,7 +109,7 @@ DMs:
|
||||
- Approve via:
|
||||
- `openclaw pairing list signal`
|
||||
- `openclaw pairing approve signal <CODE>`
|
||||
- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/start/pairing)
|
||||
- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/channels/pairing)
|
||||
- UUID-only senders (from `sourceUuid`) are stored as `uuid:<id>` in `channels.signal.allowFrom`.
|
||||
|
||||
Groups:
|
||||
|
||||
@@ -157,10 +157,21 @@ More help: [Channel troubleshooting](/channels/troubleshooting).
|
||||
Notes:
|
||||
|
||||
- Custom commands are **menu entries only**; OpenClaw does not implement them unless you handle them elsewhere.
|
||||
- Some commands can be handled by plugins/skills without being registered in Telegram’s command menu. These still work when typed (they just won't show up in `/commands` / the menu).
|
||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
|
||||
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
|
||||
|
||||
### Device pairing commands (`device-pair` plugin)
|
||||
|
||||
If the `device-pair` plugin is installed, it adds a Telegram-first flow for pairing a new phone:
|
||||
|
||||
1. `/pair` generates a setup code (sent as a separate message for easy copy/paste).
|
||||
2. Paste the setup code in the iOS app to connect.
|
||||
3. `/pair approve` approves the latest pending device request.
|
||||
|
||||
More details: [Pairing](/channels/pairing#pair-via-telegram-recommended-for-ios).
|
||||
|
||||
## Limits
|
||||
|
||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||
@@ -351,7 +362,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
|
||||
- Approve via:
|
||||
- `openclaw pairing list telegram`
|
||||
- `openclaw pairing approve telegram <CODE>`
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/channels/pairing)
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
|
||||
|
||||
#### Finding your Telegram user ID
|
||||
@@ -452,6 +463,25 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media
|
||||
}
|
||||
```
|
||||
|
||||
## Video messages (video vs video note)
|
||||
|
||||
Telegram distinguishes **video notes** (round bubble) from **video files** (rectangular).
|
||||
OpenClaw defaults to video files.
|
||||
|
||||
For message tool sends, set `asVideoNote: true` with a video `media` URL:
|
||||
|
||||
```json5
|
||||
{
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
to: "123456789",
|
||||
media: "https://example.com/video.mp4",
|
||||
asVideoNote: true,
|
||||
}
|
||||
```
|
||||
|
||||
(Note: Video notes do not support captions. If you provide a message text, it will be sent as a separate message.)
|
||||
|
||||
## Stickers
|
||||
|
||||
OpenClaw supports receiving and sending Telegram stickers with intelligent caching.
|
||||
|
||||
@@ -30,7 +30,7 @@ Local checkout (when running from a git repo):
|
||||
openclaw plugins install ./extensions/tlon
|
||||
```
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Local checkout (when running from a git repo):
|
||||
openclaw plugins install ./extensions/twitch
|
||||
```
|
||||
|
||||
Details: [Plugins](/plugin)
|
||||
Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ Pairing is a DM gate for unknown senders:
|
||||
- Codes expire after 1 hour; pending requests are capped at 3 per channel.
|
||||
|
||||
**Can multiple people use different OpenClaw instances on one WhatsApp number?**
|
||||
Yes, by routing each sender to a different agent via `bindings` (peer `kind: "dm"`, sender E.164 like `+15551234567`). Replies still come from the **same WhatsApp account**, and direct chats collapse to each agent’s main session, so use **one agent per person**. DM access control (`dmPolicy`/`allowFrom`) is global per WhatsApp account. See [Multi-Agent Routing](/concepts/multi-agent).
|
||||
Yes, by routing each sender to a different agent via `bindings` (peer `kind: "direct"`, sender E.164 like `+15551234567`). Replies still come from the **same WhatsApp account**, and direct chats collapse to each agent's main session, so use **one agent per person**. DM access control (`dmPolicy`/`allowFrom`) is global per WhatsApp account. See [Multi-Agent Routing](/concepts/multi-agent).
|
||||
|
||||
**Why do you ask for my phone number in the wizard?**
|
||||
The wizard uses it to set your **allowlist/owner** so your own DMs are permitted. It’s not used for auto-sending. If you run on your personal WhatsApp number, use that same number and enable `channels.whatsapp.selfChatMode`.
|
||||
|
||||
@@ -15,7 +15,7 @@ Zalo ships as a plugin and is not bundled with the core install.
|
||||
|
||||
- Install via CLI: `openclaw plugins install @openclaw/zalo`
|
||||
- Or select **Zalo** during onboarding and confirm the install prompt
|
||||
- Details: [Plugins](/plugin)
|
||||
- Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
@@ -104,7 +104,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
- Approve via:
|
||||
- `openclaw pairing list zalo`
|
||||
- `openclaw pairing approve zalo <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- Pairing is the default token exchange. Details: [Pairing](/channels/pairing)
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
|
||||
|
||||
## Long-polling vs webhook
|
||||
|
||||
@@ -18,7 +18,7 @@ Zalo Personal ships as a plugin and is not bundled with the core install.
|
||||
|
||||
- Install via CLI: `openclaw plugins install @openclaw/zalouser`
|
||||
- Or from a source checkout: `openclaw plugins install ./extensions/zalouser`
|
||||
- Details: [Plugins](/plugin)
|
||||
- Details: [Plugins](/tools/plugin)
|
||||
|
||||
## Prerequisite: zca-cli
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ output internal. `--deliver` remains as a deprecated alias for `--announce`.
|
||||
|
||||
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
|
||||
|
||||
Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run.
|
||||
|
||||
## Common edits
|
||||
|
||||
Update delivery settings without changing the message:
|
||||
|
||||
@@ -12,8 +12,8 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
|
||||
|
||||
Related:
|
||||
|
||||
- Hooks: [Hooks](/hooks)
|
||||
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
||||
- Hooks: [Hooks](/automation/hooks)
|
||||
- Plugin hooks: [Plugins](/tools/plugin#plugin-hooks)
|
||||
|
||||
## List All Hooks
|
||||
|
||||
@@ -248,7 +248,7 @@ openclaw hooks enable session-memory
|
||||
|
||||
**Output:** `~/.openclaw/workspace/memory/YYYY-MM-DD-slug.md`
|
||||
|
||||
**See:** [session-memory documentation](/hooks#session-memory)
|
||||
**See:** [session-memory documentation](/automation/hooks#session-memory)
|
||||
|
||||
### command-logger
|
||||
|
||||
@@ -275,7 +275,7 @@ cat ~/.openclaw/logs/commands.log | jq .
|
||||
grep '"action":"new"' ~/.openclaw/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/hooks#command-logger)
|
||||
**See:** [command-logger documentation](/automation/hooks#command-logger)
|
||||
|
||||
### soul-evil
|
||||
|
||||
@@ -301,4 +301,4 @@ Runs `BOOT.md` when the gateway starts (after channels start).
|
||||
openclaw hooks enable boot-md
|
||||
```
|
||||
|
||||
**See:** [boot-md documentation](/hooks#boot-md)
|
||||
**See:** [boot-md documentation](/automation/hooks#boot-md)
|
||||
|
||||
@@ -255,7 +255,7 @@ Manage extensions and their config:
|
||||
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||
- `openclaw plugins doctor` — report plugin load errors.
|
||||
|
||||
Most plugin changes require a gateway restart. See [/plugin](/plugin).
|
||||
Most plugin changes require a gateway restart. See [/plugin](/tools/plugin).
|
||||
|
||||
## Memory
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user